Class: Railstart::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/railstart/config.rb

Overview

Provides loading, merging, and validation of Railstart configuration data.

Combines built-in defaults with optional user overrides and exposes helpers for downstream components.

Constant Summary collapse

BUILTIN_CONFIG_PATH =
File.expand_path("../../config/rails8_defaults.yaml", __dir__)
USER_CONFIG_PATH =
File.expand_path("~/.config/railstart/config.yaml")
QUESTION_TYPES =
%w[select multi_select yes_no input].freeze
CHOICE_REQUIRED_TYPES =
%w[select multi_select].freeze
MERGEABLE_COLLECTIONS =
%w[questions post_actions].freeze

Class Method Summary collapse

Class Method Details

.deep_dup(value) ⇒ Object (private)



270
271
272
273
274
275
276
277
278
279
# File 'lib/railstart/config.rb', line 270

def deep_dup(value)
  case value
  when Hash
    value.transform_values { |v| deep_dup(v) }
  when Array
    value.map { |v| deep_dup(v) }
  else
    value
  end
end

.deep_merge_hash(base, override) ⇒ Object (private)



89
90
91
92
93
94
95
96
97
# File 'lib/railstart/config.rb', line 89

def deep_merge_hash(base, override)
  return deep_dup(base || {}) if override.nil? || override.empty?

  result = deep_dup(base || {})
  override.each do |key, override_value|
    result[key] = deep_merge_value(key, result[key], override_value)
  end
  result
end

.deep_merge_value(key, left, right) ⇒ Object (private)



99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/railstart/config.rb', line 99

def deep_merge_value(key, left, right)
  return deep_dup(left) if right.nil?
  return deep_dup(right) if left.nil?

  if special_array_key?(key)
    merge_id_array(left, right)
  elsif left.is_a?(Hash) && right.is_a?(Hash)
    deep_merge_hash(left, right)
  else
    deep_dup(right)
  end
end

.fetch_id(entry) ⇒ Object (private)



157
158
159
160
161
# File 'lib/railstart/config.rb', line 157

def fetch_id(entry)
  return unless entry.respond_to?(:[])

  entry["id"] || entry[:id]
end

.interpolate_flag(template, value) ⇒ String

Interpolate %{value} placeholders within Rails flags.

Examples:

Railstart::Config.interpolate_flag("--database=%{value}", "postgresql")
# => "--database=postgresql"

Parameters:

  • template (String)

    flag template

  • value (Object)

    value to substitute into the template

Returns:

  • (String)

    interpolated flag string

Raises:



53
54
55
56
57
58
59
# File 'lib/railstart/config.rb', line 53

def interpolate_flag(template, value)
  return template if template.nil? || (!template.include?("%{") && !template.include?("%<"))

  format(template, value: value)
rescue KeyError => e
  raise ConfigError, "Invalid interpolation token in rails_flag \"#{template}\": #{e.message}"
end

.load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH, preset_path: nil) ⇒ Hash

Load, merge, and validate configuration from built-in, user, and preset sources.

Examples:

config = Railstart::Config.load

With preset

config = Railstart::Config.load(preset_path: "~/.config/railstart/presets/api-only.yaml")

Parameters:

  • builtin_path (String) (defaults to: BUILTIN_CONFIG_PATH)

    path to default config YAML shipped with the gem

  • user_path (String) (defaults to: USER_CONFIG_PATH)

    optional user override YAML path

  • preset_path (String) (defaults to: nil)

    optional preset YAML path (third overlay)

Returns:

  • (Hash)

    deep-copied, merged, validated configuration hash

Raises:



32
33
34
35
36
37
38
39
40
41
# File 'lib/railstart/config.rb', line 32

def load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH, preset_path: nil)
  builtin = read_yaml(builtin_path, required: true)
  user = read_yaml(user_path, required: false)
  preset = preset_path ? read_yaml(preset_path, required: false) : {}

  merged = merge_config(builtin, user)
  merged = merge_config(merged, preset) unless preset.empty?
  validate!(merged)
  merged
end

.merge_config(base, override) ⇒ Object (private)



82
83
84
85
86
87
# File 'lib/railstart/config.rb', line 82

def merge_config(base, override)
  normalized_base = base || {}
  return deep_dup(normalized_base) if override.nil? || override.empty?

  deep_merge_hash(normalized_base, override)
end

.merge_entries(left, right) ⇒ Object (private)



146
147
148
149
150
151
152
153
154
155
# File 'lib/railstart/config.rb', line 146

def merge_entries(left, right)
  return deep_dup(right) if left.nil?
  return deep_dup(left) if right.nil?

  if left.is_a?(Hash) && right.is_a?(Hash)
    deep_merge_hash(left, right)
  else
    deep_dup(right)
  end
end

.merge_id_array(base, override) ⇒ Object (private)



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
# File 'lib/railstart/config.rb', line 112

def merge_id_array(base, override)
  base_entries = Array(base)
  override_entries = Array(override)

  map = {}
  order = []
  base_without_id = []

  base_entries.each do |entry|
    copy = deep_dup(entry)
    id = fetch_id(copy)
    if id
      order << id unless order.include?(id)
      map[id] = copy
    else
      base_without_id << copy
    end
  end

  override_without_id = []
  override_entries.each do |entry|
    copy = deep_dup(entry)
    id = fetch_id(copy)
    if id
      order << id unless order.include?(id)
      map[id] = merge_entries(map[id], copy)
    else
      override_without_id << copy
    end
  end

  order.map { |id| map[id] } + base_without_id + override_without_id
end

.read_yaml(path, required:) ⇒ Object (private)



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/railstart/config.rb', line 63

def read_yaml(path, required:)
  return {} if path.nil? || path.to_s.empty?

  unless File.exist?(path)
    raise ConfigLoadError, "Missing required config file: #{path}" if required

    return {}
  end

  data = YAML.safe_load_file(path, aliases: true) || {}
  raise ConfigLoadError, "Config file #{path} must define a Hash at the top level" unless data.is_a?(Hash)

  deep_dup(data)
rescue Errno::EACCES => e
  raise ConfigLoadError, "Cannot read #{path}: #{e.message}"
rescue Psych::Exception => e
  raise ConfigLoadError, "Failed to parse #{path}: #{e.message}"
end

.special_array_key?(key) ⇒ Boolean (private)

Returns:

  • (Boolean)


281
282
283
# File 'lib/railstart/config.rb', line 281

def special_array_key?(key)
  key && MERGEABLE_COLLECTIONS.include?(key.to_s)
end

.validate!(config) ⇒ Object (private)



163
164
165
166
167
168
169
170
171
172
# File 'lib/railstart/config.rb', line 163

def validate!(config)
  issues = []
  question_ids = Array(config["questions"]).map { |e| fetch_id(e) }.compact

  MERGEABLE_COLLECTIONS.each do |collection|
    entries = Array(config[collection])
    issues.concat(validate_collection(collection, entries, question_ids))
  end
  raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
end

.validate_collection(name, entries, question_ids = []) ⇒ Object (private)



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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/railstart/config.rb', line 174

def validate_collection(name, entries, question_ids = [])
  issues = []
  id_counts = Hash.new(0)

  entries.each_with_index do |entry, index|
    unless entry.is_a?(Hash)
      issues << "#{name} entry at index #{index} must be a Hash"
      next
    end

    id = fetch_id(entry)
    if id.nil? || id.to_s.strip.empty?
      issues << "#{name} entry at index #{index} is missing an id"
    else
      id_counts[id] += 1
    end

    if name == "questions"
      type = entry["type"] || entry[:type]
      unless QUESTION_TYPES.include?(type)
        issues << "Question #{id || index} has invalid type #{type.inspect}"
        next
      end

      issues.concat(validate_question_choices(entry, id || index)) if CHOICE_REQUIRED_TYPES.include?(type)
    elsif name == "post_actions"
      issues.concat(validate_post_action_entry(entry, id || index)) if entry.fetch("enabled", true)

      if_condition = entry["if"] || entry[:if]
      if if_condition.is_a?(Hash)
        ref_question_id = if_condition["question"] || if_condition[:question]
        if ref_question_id && !question_ids.include?(ref_question_id)
          issues << "Post-action #{id || index} references unknown question '#{ref_question_id}'"
        end
      end
    end
  end

  id_counts.each do |id, count|
    issues << "#{name} entry id #{id} is defined #{count} times" if count > 1
  end

  issues
end

.validate_post_action_entry(entry, identifier) ⇒ Object (private)



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/railstart/config.rb', line 244

def validate_post_action_entry(entry, identifier)
  action_type = (entry["type"] || entry[:type] || "command").to_s

  case action_type
  when "command"
    command = entry["command"] || entry[:command]
    if command.nil? || command.to_s.strip.empty?
      ["Post-action #{identifier} is enabled but missing a command"]
    else
      []
    end
  when "template"
    issues = []
    source = entry["source"] || entry[:source]
    if source.nil? || source.to_s.strip.empty?
      issues << "Post-action #{identifier} is a template but missing a source"
    end

    variables = entry["variables"] || entry[:variables]
    issues << "Post-action #{identifier} template variables must be a Hash" if variables && !variables.is_a?(Hash)
    issues
  else
    ["Post-action #{identifier} has unsupported type '#{action_type}'"]
  end
end

.validate_question_choices(entry, question_id) ⇒ Object (private)



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/railstart/config.rb', line 219

def validate_question_choices(entry, question_id)
  issues = []
  choices = entry["choices"] || entry[:choices]

  if !choices.is_a?(Array) || choices.empty?
    issues << "Question #{question_id} (#{entry["type"]}) must define at least one choice"
    return issues
  end

  choices.each_with_index do |choice, cidx|
    unless choice.is_a?(Hash)
      issues << "Question #{question_id} choice at index #{cidx} must be a Hash"
      next
    end
    unless choice["name"] || choice[:name]
      issues << "Question #{question_id} choice at index #{cidx} missing 'name'"
    end
    unless choice["value"] || choice[:value]
      issues << "Question #{question_id} choice at index #{cidx} missing 'value'"
    end
  end

  issues
end