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)



452
453
454
455
456
457
458
459
460
461
# File 'lib/railstart/config.rb', line 452

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)



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

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)



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

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

.duplicate_choice_issues(choices, question_id, field) ⇒ Object (private)



298
299
300
301
302
303
304
305
# File 'lib/railstart/config.rb', line 298

def duplicate_choice_issues(choices, question_id, field)
  values = choices.filter_map do |choice|
    value_for(choice, field) if choice.is_a?(Hash) && key_present?(choice, field)
  end
  values.tally.filter_map do |value, count|
    "Question #{question_id} has duplicate choice #{field} #{value.inspect}" if count > 1
  end
end

.duplicate_id_issues(collection, entries) ⇒ Object (private)



182
183
184
185
186
187
# File 'lib/railstart/config.rb', line 182

def duplicate_id_issues(collection, entries)
  counts = entries.filter_map { |entry| fetch_id(entry) }.tally
  counts.filter_map do |id, count|
    "#{collection} entry id #{id} is defined #{count} times" if count > 1
  end
end

.fetch_id(entry) ⇒ Object (private)



159
160
161
162
163
# File 'lib/railstart/config.rb', line 159

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:



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

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

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

.interpolation_issues(flag, label, field) ⇒ Object (private)



355
356
357
358
359
360
# File 'lib/railstart/config.rb', line 355

def interpolation_issues(flag, label, field)
  interpolate_flag(flag, "value")
  []
rescue ConfigError => e
  ["#{label} has invalid #{field} interpolation: #{e.message}"]
end

.key_present?(hash, key) ⇒ Boolean (private)

Returns:

  • (Boolean)


442
443
444
# File 'lib/railstart/config.rb', line 442

def key_present?(hash, key)
  hash.key?(key) || hash.key?(key.to_sym)
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
42
43
# 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) : {}

  validate_layer_ids!(builtin, user, preset)

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

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



84
85
86
87
88
89
# File 'lib/railstart/config.rb', line 84

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)



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

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)



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

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)



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

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)


463
464
465
# File 'lib/railstart/config.rb', line 463

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

.unsupported_key_issues(hash, allowed, label) ⇒ Object (private)



437
438
439
440
# File 'lib/railstart/config.rb', line 437

def unsupported_key_issues(hash, allowed, label)
  unsupported = hash.keys.map(&:to_s) - allowed
  unsupported.map { |key| "#{label} has unsupported key '#{key}'" }
end

.validate!(config) ⇒ Object (private)



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

def validate!(config)
  questions = Array(config["questions"])
  post_actions = Array(config["post_actions"])
  issues = validate_questions(questions)
  issues.concat(validate_post_actions(post_actions, questions))
  raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
end

.validate_depends_on(entry, identifier, index, question_positions) ⇒ Object (private)



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/railstart/config.rb', line 362

def validate_depends_on(entry, identifier, index, question_positions)
  return [] unless key_present?(entry, "depends_on")

  condition = value_for(entry, "depends_on")
  return ["Question #{identifier} depends_on must be a Hash"] unless condition.is_a?(Hash)

  issues = unsupported_key_issues(condition, %w[question value], "Question #{identifier} depends_on")
  reference = value_for(condition, "question")
  issues << "Question #{identifier} depends_on is missing question" if reference.to_s.strip.empty?
  issues << "Question #{identifier} depends_on is missing value" unless key_present?(condition, "value")
  if !reference.to_s.empty? && !question_positions.key?(reference)
    issues << "Question #{identifier} references unknown question '#{reference}'"
  elsif question_positions[reference] && question_positions[reference] >= index
    issues << "Question #{identifier} depends_on must reference an earlier question"
  end
  issues
end

.validate_flag_source(source, label) ⇒ Object (private)



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/railstart/config.rb', line 333

def validate_flag_source(source, label)
  issues = []
  if key_present?(source, "rails_flag")
    flag = value_for(source, "rails_flag")
    if flag.is_a?(String)
      issues.concat(interpolation_issues(flag, label, "rails_flag"))
    else
      issues << "#{label} rails_flag must be a String"
    end
  end

  if key_present?(source, "rails_flags")
    flags = value_for(source, "rails_flags")
    if !flags.is_a?(Array) || flags.any? { |flag| !flag.is_a?(String) }
      issues << "#{label} rails_flags must be an Array of Strings"
    else
      flags.each { |flag| issues.concat(interpolation_issues(flag, label, "rails_flags")) }
    end
  end
  issues
end

.validate_layer_ids!(*layers) ⇒ Object (private)



173
174
175
176
177
178
179
180
# File 'lib/railstart/config.rb', line 173

def validate_layer_ids!(*layers)
  issues = layers.flat_map do |layer|
    MERGEABLE_COLLECTIONS.flat_map do |collection|
      duplicate_id_issues(collection, Array(layer[collection]))
    end
  end
  raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
end

.validate_post_action_condition(entry, identifier, question_ids) ⇒ Object (private)



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/railstart/config.rb', line 414

def validate_post_action_condition(entry, identifier, question_ids)
  return [] unless key_present?(entry, "if")

  condition = value_for(entry, "if")
  return ["Post-action #{identifier} condition must be a Hash"] unless condition.is_a?(Hash)

  issues = unsupported_key_issues(condition, %w[question equals includes], "Post-action #{identifier} condition")
  reference = value_for(condition, "question")
  issues << "Post-action #{identifier} condition is missing question" if reference.to_s.strip.empty?
  if !reference.to_s.empty? && !question_ids.include?(reference)
    issues << "Post-action #{identifier} references unknown question '#{reference}'"
  end

  operators = %w[equals includes].select { |key| key_present?(condition, key) }
  unless operators.one?
    issues << "Post-action #{identifier} condition must define exactly one of equals or includes"
  end
  if key_present?(condition, "includes") && !value_for(condition, "includes").is_a?(Array)
    issues << "Post-action #{identifier} condition includes must be an Array"
  end
  issues
end

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



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/railstart/config.rb', line 380

def validate_post_action_entry(entry, identifier)
  action_type = (value_for(entry, "type") || "command").to_s
  enabled = entry.fetch("enabled", entry.fetch(:enabled, true))

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

    variables = value_for(entry, "variables")
    issues << "Post-action #{identifier} template variables must be a Hash" if variables && !variables.is_a?(Hash)
    if variables.is_a?(Hash)
      variables.each_key do |key|
        unless /\A[a-zA-Z_]\w*\z/.match?(key.to_s)
          issues << "Post-action #{identifier} has invalid template variable name #{key.inspect}"
        end
      end
    end
    issues
  else
    ["Post-action #{identifier} has unsupported type '#{action_type}'"]
  end
end

.validate_post_actions(entries, questions) ⇒ Object (private)



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/railstart/config.rb', line 232

def validate_post_actions(entries, questions)
  issues = []
  id_counts = Hash.new(0)
  question_ids = questions.filter_map { |entry| fetch_id(entry) }

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

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

    identifier = id || index
    issues.concat(validate_post_action_entry(entry, identifier))
    issues.concat(validate_post_action_condition(entry, identifier, question_ids))
  end

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

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



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/railstart/config.rb', line 261

def validate_question_choices(entry, question_id)
  issues = []
  choices = value_for(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 key_present?(choice, "name") && !value_for(choice, "name").to_s.strip.empty?
      issues << "Question #{question_id} choice at index #{cidx} missing 'name'"
    end
    unless key_present?(choice, "value") && !value_for(choice, "value").nil?
      issues << "Question #{question_id} choice at index #{cidx} missing 'value'"
    end
    if key_present?(choice, "default") && ![true, false].include?(value_for(choice, "default"))
      issues << "Question #{question_id} choice at index #{cidx} default must be true or false"
    end
    issues.concat(validate_flag_source(choice, "Question #{question_id} choice at index #{cidx}"))
  end

  issues.concat(duplicate_choice_issues(choices, question_id, "name"))
  issues.concat(duplicate_choice_issues(choices, question_id, "value"))
  default_count = choices.count do |choice|
    choice.is_a?(Hash) && value_for(choice, "default") == true
  end
  if value_for(entry, "type") == "select" && default_count > 1
    issues << "Question #{question_id} must define at most one default choice"
  end
  issues
end

.validate_question_default(entry, identifier, type) ⇒ Object (private)



307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/railstart/config.rb', line 307

def validate_question_default(entry, identifier, type)
  return [] unless key_present?(entry, "default")

  default = value_for(entry, "default")
  choices = Array(value_for(entry, "choices"))
  choice_values = choices.filter_map do |choice|
    value_for(choice, "value") if choice.is_a?(Hash) && key_present?(choice, "value")
  end

  case type
  when "select"
    return [] if choice_values.include?(default)

    ["Question #{identifier} default #{default.inspect} is not a defined choice"]
  when "multi_select"
    return ["Question #{identifier} default must be an Array"] unless default.is_a?(Array)

    unknown = default - choice_values
    unknown.empty? ? [] : ["Question #{identifier} has unknown default choice #{unknown.first.inspect}"]
  when "yes_no"
    [true, false].include?(default) ? [] : ["Question #{identifier} default must be true or false"]
  else
    []
  end
end

.validate_questions(entries) ⇒ Object (private)



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
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/railstart/config.rb', line 189

def validate_questions(entries)
  issues = []
  id_counts = Hash.new(0)
  question_positions = entries.each_with_index.filter_map do |entry, index|
    id = fetch_id(entry)
    [id, index] if id
  end.to_h

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

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

    identifier = id || index
    type = value_for(entry, "type")
    unless QUESTION_TYPES.include?(type)
      issues << "Question #{identifier} has invalid type #{type.inspect}"
      next
    end

    prompt = value_for(entry, "prompt")
    issues << "Question #{identifier} is missing a prompt" if prompt.nil? || prompt.to_s.strip.empty?
    issues.concat(validate_question_choices(entry, identifier)) if CHOICE_REQUIRED_TYPES.include?(type)
    issues.concat(validate_question_default(entry, identifier, type))
    issues.concat(validate_flag_source(entry, "Question #{identifier}"))
    issues.concat(validate_depends_on(entry, identifier, index, question_positions))
  end

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

  issues
end

.value_for(hash, key) ⇒ Object (private)



446
447
448
449
450
# File 'lib/railstart/config.rb', line 446

def value_for(hash, key)
  return hash[key] if hash.key?(key)

  hash[key.to_sym]
end