tty-config 0.3.1 → 0.5.1

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/LICENSE.txt +1 -1
  4. data/README.md +305 -153
  5. data/lib/tty/config/dependency_loader.rb +55 -0
  6. data/lib/tty/config/generator.rb +57 -0
  7. data/lib/tty/config/marshaller.rb +66 -0
  8. data/lib/tty/config/marshaller_registry.rb +45 -0
  9. data/lib/tty/config/marshallers/hcl_marshaller.rb +30 -0
  10. data/lib/tty/config/marshallers/ini_marshaller.rb +31 -0
  11. data/lib/tty/config/marshallers/java_props_marshaller.rb +28 -0
  12. data/lib/tty/config/marshallers/json_marshaller.rb +28 -0
  13. data/lib/tty/config/marshallers/toml_marshaller.rb +28 -0
  14. data/lib/tty/config/marshallers/yaml_marshaller.rb +32 -0
  15. data/lib/tty/config/marshallers.rb +35 -0
  16. data/lib/tty/config/version.rb +3 -3
  17. data/lib/tty/config.rb +374 -190
  18. data/lib/tty-config.rb +1 -1
  19. metadata +66 -57
  20. data/Rakefile +0 -8
  21. data/bin/console +0 -14
  22. data/bin/setup +0 -8
  23. data/spec/spec_helper.rb +0 -54
  24. data/spec/unit/alias_setting_spec.rb +0 -72
  25. data/spec/unit/append_spec.rb +0 -26
  26. data/spec/unit/autoload_env_spec.rb +0 -62
  27. data/spec/unit/delete_spec.rb +0 -22
  28. data/spec/unit/exist_spec.rb +0 -24
  29. data/spec/unit/fetch_spec.rb +0 -45
  30. data/spec/unit/generate_spec.rb +0 -70
  31. data/spec/unit/merge_spec.rb +0 -13
  32. data/spec/unit/new_spec.rb +0 -6
  33. data/spec/unit/normalize_hash_spec.rb +0 -21
  34. data/spec/unit/read_spec.rb +0 -109
  35. data/spec/unit/remove_spec.rb +0 -16
  36. data/spec/unit/set_from_env_spec.rb +0 -78
  37. data/spec/unit/set_if_empty_spec.rb +0 -26
  38. data/spec/unit/set_spec.rb +0 -62
  39. data/spec/unit/validate_spec.rb +0 -76
  40. data/spec/unit/write_spec.rb +0 -197
  41. data/tasks/console.rake +0 -11
  42. data/tasks/coverage.rake +0 -11
  43. data/tasks/spec.rake +0 -29
  44. data/tty-config.gemspec +0 -30
data/lib/tty/config.rb CHANGED
@@ -1,11 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pathname'
3
+ require "pathname"
4
4
 
5
- require_relative 'config/version'
5
+ require_relative "config/version"
6
+ require_relative "config/marshallers"
7
+ require_relative "config/marshallers/ini_marshaller"
8
+ require_relative "config/marshallers/json_marshaller"
9
+ require_relative "config/marshallers/yaml_marshaller"
10
+ require_relative "config/marshallers/toml_marshaller"
11
+ require_relative "config/marshallers/hcl_marshaller"
12
+ require_relative "config/marshallers/java_props_marshaller"
6
13
 
7
14
  module TTY
15
+ # Responsible for managing application configuration
16
+ #
17
+ # @api public
8
18
  class Config
19
+ include Marshallers
20
+
21
+ # Error raised when failed to load a dependency
22
+ DependencyLoadError = Class.new(StandardError)
9
23
  # Error raised when key fails validation
10
24
  ReadError = Class.new(StandardError)
11
25
  # Error raised when issues writing configuration to a file
@@ -15,80 +29,33 @@ module TTY
15
29
  # Error raised when validation assertion fails
16
30
  ValidationError = Class.new(StandardError)
17
31
 
32
+ # Coerce a hash object into Config instance
33
+ #
34
+ # @return [TTY::Config]
35
+ #
36
+ # @api private
18
37
  def self.coerce(hash, &block)
19
38
  new(normalize_hash(hash), &block)
20
39
  end
21
40
 
22
41
  # Convert string keys via method
23
42
  #
43
+ # @param [Hash] hash
44
+ # the hash to normalize keys for
45
+ # @param [Symbol] method
46
+ # the method to use for converting keys
47
+ #
48
+ # @return [Hash{Symbol => Object}]
49
+ # the converted hash
50
+ #
24
51
  # @api private
25
52
  def self.normalize_hash(hash, method = :to_sym)
26
- hash.reduce({}) do |acc, (key, val)|
53
+ hash.each_with_object({}) do |(key, val), acc|
27
54
  value = val.is_a?(::Hash) ? normalize_hash(val, method) : val
28
55
  acc[key.public_send(method)] = value
29
- acc
30
- end
31
- end
32
-
33
- # Generate file content based on the data hash
34
- #
35
- # @param [Hash] data
36
- #
37
- # @return [String]
38
- # the file content
39
- #
40
- # @api public
41
- def self.generate(data, separator: '=')
42
- content = []
43
- values = {}
44
- sections = {}
45
-
46
- data.keys.sort.each do |key|
47
- val = data[key]
48
- if val.is_a?(NilClass)
49
- next
50
- elsif val.is_a?(Hash) ||
51
- (val.is_a?(Array) && val.first.is_a?(Hash))
52
- sections[key] = val
53
- elsif val.is_a?(Array)
54
- values[key] = val.join(',')
55
- else
56
- values[key] = val
57
- end
58
- end
59
-
60
- # values
61
- values.each do |key, val|
62
- content << "#{key} #{separator} #{val}"
63
- end
64
- content << '' unless values.empty?
65
-
66
- # sections
67
- sections.each do |section, object|
68
- next if object.empty? # only add section if values present
69
-
70
- content << "[#{section}]"
71
- if object.is_a?(Array)
72
- object = object.reduce({}, :merge!)
73
- end
74
- object.each do |key, val|
75
- val = val.join(',') if val.is_a?(Array)
76
- content << "#{key} #{separator} #{val}" if val
77
- end
78
- content << ''
79
56
  end
80
- content.join("\n")
81
57
  end
82
58
 
83
- # Storage for suppported format & extensions pairs
84
- # @api public
85
- EXTENSIONS = {
86
- yaml: %w(.yaml .yml),
87
- json: %w(.json),
88
- toml: %w(.toml),
89
- ini: %w(.ini .cnf .conf .cfg .cf)
90
- }.freeze
91
-
92
59
  # A collection of config paths
93
60
  # @api public
94
61
  attr_reader :location_paths
@@ -113,22 +80,33 @@ module TTY
113
80
  # @api public
114
81
  attr_accessor :env_prefix
115
82
 
83
+ # The string used to separate parts in ENV variable name
84
+ # @api public
85
+ attr_accessor :env_separator
86
+
116
87
  # Create a configuration instance
117
88
  #
118
89
  # @api public
119
- def initialize(**settings)
90
+ def initialize(settings = {})
120
91
  @settings = settings
121
92
  @location_paths = []
122
93
  @validators = {}
123
- @filename = 'config'
124
- @extname = '.yml'
125
- @extensions = EXTENSIONS.values.flatten << ''
126
- @key_delim = '.'
94
+ @filename = "config"
95
+ @extname = ".yml"
96
+ @key_delim = "."
127
97
  @envs = {}
128
- @env_prefix = ''
98
+ @env_prefix = ""
99
+ @env_separator = "_"
129
100
  @autoload_env = false
130
101
  @aliases = {}
131
102
 
103
+ register_marshaller :yaml, Marshallers::YAMLMarshaller
104
+ register_marshaller :json, Marshallers::JSONMarshaller
105
+ register_marshaller :toml, Marshallers::TOMLMarshaller
106
+ register_marshaller :ini, Marshallers::INIMarshaller
107
+ register_marshaller :hcl, Marshallers::HCLMarshaller
108
+ register_marshaller :jprops, Marshallers::JavaPropsMarshaller
109
+
132
110
  yield(self) if block_given?
133
111
  end
134
112
 
@@ -138,14 +116,23 @@ module TTY
138
116
  #
139
117
  # api public
140
118
  def extname=(name)
141
- unless @extensions.include?(name)
119
+ unless extensions.include?(name)
142
120
  raise UnsupportedExtError, "Config file format `#{name}` is not supported."
143
121
  end
122
+
144
123
  @extname = name
145
124
  end
146
125
 
147
126
  # Add path to locations to search in
148
127
  #
128
+ # @example
129
+ # append_path(Dir.pwd)
130
+ #
131
+ # @param [String] path
132
+ # the path to append
133
+ #
134
+ # @return [Array<String>]
135
+ #
149
136
  # @api public
150
137
  def append_path(path)
151
138
  @location_paths << path
@@ -153,6 +140,14 @@ module TTY
153
140
 
154
141
  # Insert location path at the begining
155
142
  #
143
+ # @example
144
+ # prepend_path(Dir.pwd)
145
+ #
146
+ # @param [String] path
147
+ # the path to prepend
148
+ #
149
+ # @return [Array<String>]
150
+ #
156
151
  # @api public
157
152
  def prepend_path(path)
158
153
  @location_paths.unshift(path)
@@ -160,6 +155,8 @@ module TTY
160
155
 
161
156
  # Check if env variables are auto loaded
162
157
  #
158
+ # @return [Boolean]
159
+ #
163
160
  # @api public
164
161
  def autoload_env?
165
162
  @autoload_env == true
@@ -172,9 +169,26 @@ module TTY
172
169
  @autoload_env = true
173
170
  end
174
171
 
175
- # Set a value for a composite key and overrides any existing keys.
172
+ # Set a value for a composite key and overrides any existing keys
176
173
  # Keys are case-insensitive
177
174
  #
175
+ # @example
176
+ # set(:foo, :bar, :baz, value: 2)
177
+ #
178
+ # @example
179
+ # set(:foo, :bar, :baz) { 2 }
180
+ #
181
+ # @example
182
+ # set("foo.bar.baz", value: 2)
183
+ #
184
+ # @param [Array<String, Symbol>, String] keys
185
+ # the nested key to set value for
186
+ # @param [Object] value
187
+ # the value to set
188
+ #
189
+ # @return [Object]
190
+ # the set value
191
+ #
178
192
  # @api public
179
193
  def set(*keys, value: nil, &block)
180
194
  assert_either_value_or_block(value, block)
@@ -198,12 +212,22 @@ module TTY
198
212
 
199
213
  # Set a value for a composite key if not present already
200
214
  #
201
- # @param [Array[String|Symbol]] keys
215
+ # @example
216
+ # set_if_empty(:foo, :bar, :baz, value: 2)
217
+ #
218
+ # @param [Array<String, Symbol>] keys
202
219
  # the keys to set value for
220
+ # @param [Object] value
221
+ # the value to set
222
+ #
223
+ # @return [Object, nil]
224
+ # the set value or nil
203
225
  #
204
226
  # @api public
205
227
  def set_if_empty(*keys, value: nil, &block)
206
- return unless deep_find(@settings, keys.last.to_s).nil?
228
+ keys = convert_to_keys(keys)
229
+ return unless deep_fetch(@settings, *keys).nil?
230
+
207
231
  block ? set(*keys, &block) : set(*keys, value: value)
208
232
  end
209
233
 
@@ -213,12 +237,11 @@ module TTY
213
237
  # set_from_env(:host)
214
238
  # set_from_env(:foo, :bar) { 'HOST' }
215
239
  #
216
- # @param [Array[String]] keys
240
+ # @param [Array<String>] keys
217
241
  # the keys to bind to ENV variables
218
242
  #
219
243
  # @api public
220
244
  def set_from_env(*keys, &block)
221
- assert_keys_with_block(convert_to_keys(keys), block)
222
245
  key = flatten_keys(keys)
223
246
  env_key = block.nil? ? key : block.()
224
247
  env_key = to_env_key(env_key)
@@ -229,17 +252,32 @@ module TTY
229
252
  #
230
253
  # @param [String] key
231
254
  #
255
+ # @return [String]
256
+ #
232
257
  # @api private
233
258
  def to_env_key(key)
234
- env_key = key.to_s.upcase
235
- @env_prefix == '' ? env_key : "#{@env_prefix.to_s.upcase}_#{env_key}"
259
+ env_key = key.to_s.gsub(key_delim, env_separator).upcase
260
+ if @env_prefix == ""
261
+ env_key
262
+ else
263
+ "#{@env_prefix.to_s.upcase}#{env_separator}#{env_key}"
264
+ end
236
265
  end
237
266
 
238
267
  # Fetch value under a composite key
239
268
  #
240
- # @param [Array[String|Symbol]] keys
269
+ # @example
270
+ # fetch(:foo, :bar, :baz)
271
+ #
272
+ # @example
273
+ # fetch("foo.bar.baz")
274
+ #
275
+ # @param [Array<String, Symbol>, String] keys
241
276
  # the keys to get value at
242
277
  # @param [Object] default
278
+ # the default value
279
+ #
280
+ # @return [Object]
243
281
  #
244
282
  # @api public
245
283
  def fetch(*keys, default: nil, &block)
@@ -259,24 +297,37 @@ module TTY
259
297
  value = block || default if value.nil?
260
298
 
261
299
  while callable_without_params?(value)
262
- value = value.call
300
+ value = value.()
263
301
  end
264
302
  value
265
303
  end
266
304
 
267
305
  # Merge in other configuration settings
268
306
  #
269
- # @param [Hash[Object]] other_settings
307
+ # @param [Hash{Symbol => Object]] other_settings
308
+ #
309
+ # @return [Hash, nil]
310
+ # the combined settings or nil
270
311
  #
271
312
  # @api public
272
313
  def merge(other_settings)
314
+ return unless other_settings.respond_to?(:to_hash)
315
+
273
316
  @settings = deep_merge(@settings, other_settings)
274
317
  end
275
318
 
276
319
  # Append values to an already existing nested key
277
320
  #
278
- # @param [Array[String|Symbol]] values
321
+ # @example
322
+ # append(1, 2, to: %i[foo bar])
323
+ #
324
+ # @param [Array<Object>] values
279
325
  # the values to append
326
+ # @param [Array<String, Symbol] to
327
+ # the nested key to append to
328
+ #
329
+ # @return [Array<Object>]
330
+ # the values for a nested key
280
331
  #
281
332
  # @api public
282
333
  def append(*values, to: nil)
@@ -286,24 +337,45 @@ module TTY
286
337
 
287
338
  # Remove a set of values from a nested key
288
339
  #
289
- # @param [Array[String|Symbol]] keys
290
- # the keys for a value removal
340
+ # @example
341
+ # remove(1, 2, from: :foo)
342
+ #
343
+ # @example
344
+ # remove(1, 2, from: %i[foo bar])
345
+ #
346
+ # @param [Array<Object>] values
347
+ # the values to remove from a nested key
348
+ # @param [Array<String, Symbol>, String] from
349
+ # the nested key to remove values from
291
350
  #
292
351
  # @api public
293
352
  def remove(*values, from: nil)
294
353
  keys = Array(from)
354
+ raise ArgumentError, "Need to set key to remove from" if keys.empty?
355
+
295
356
  set(*keys, value: Array(fetch(*keys)) - values)
296
357
  end
297
358
 
298
359
  # Delete a value from a nested key
299
360
  #
300
- # @param [Array[String|Symbol]] keys
361
+ # @example
362
+ # delete(:foo, :bar, :baz)
363
+ #
364
+ # @example
365
+ # delete(:unknown) { |key| "#{key} isn't set" }
366
+ #
367
+ # @param [Array<String, Symbol>] keys
301
368
  # the keys for a value deletion
302
369
  #
370
+ # @yield [key] Invoke the block with a missing key
371
+ #
372
+ # @return [Object]
373
+ # the deleted value(s)
374
+ #
303
375
  # @api public
304
- def delete(*keys)
376
+ def delete(*keys, &default)
305
377
  keys = convert_to_keys(keys)
306
- deep_delete(*keys, @settings)
378
+ deep_delete(*keys, @settings, &default)
307
379
  end
308
380
 
309
381
  # Define an alias to a nested key
@@ -311,7 +383,7 @@ module TTY
311
383
  # @example
312
384
  # alias_setting(:foo, to: :bar)
313
385
  #
314
- # @param [Array[String]] keys
386
+ # @param [Array<String>] keys
315
387
  # the alias key
316
388
  #
317
389
  # @api public
@@ -321,11 +393,11 @@ module TTY
321
393
  alias_key = flatten_keys(alias_keys)
322
394
 
323
395
  if alias_key == flat_setting
324
- raise ArgumentError, 'Alias matches setting key'
396
+ raise ArgumentError, "Alias matches setting key"
325
397
  end
326
398
 
327
399
  if fetch(alias_key)
328
- raise ArgumentError, 'Setting already exists with an alias ' \
400
+ raise ArgumentError, "Setting already exists with an alias " \
329
401
  "'#{alias_keys.map(&:inspect).join(', ')}'"
330
402
  end
331
403
 
@@ -334,7 +406,7 @@ module TTY
334
406
 
335
407
  # Register a validation rule for a nested key
336
408
  #
337
- # @param [Array[String]] keys
409
+ # @param [Array<String>] keys
338
410
  # a deep nested keys
339
411
  # @param [Proc] validator
340
412
  # the logic to use to validate given nested key
@@ -385,37 +457,64 @@ module TTY
385
457
  # @api public
386
458
  def read(file = find_file, format: :auto)
387
459
  if file.nil?
388
- raise ReadError, 'No file found to read configuration from!'
460
+ raise ReadError, "No file found to read configuration from!"
389
461
  elsif !::File.exist?(file)
390
462
  raise ReadError, "Configuration file `#{file}` does not exist!"
391
463
  end
392
464
 
393
- merge(unmarshal(file, format: format))
465
+ set_file_metadata(file)
466
+
467
+ ext = (format == :auto ? extname : ".#{format}")
468
+ content = ::File.read(file)
469
+
470
+ merge(unmarshal(content, ext: ext))
394
471
  end
395
472
 
396
473
  # Write current configuration to a file.
397
474
  #
475
+ # @example
476
+ # write(force: true, create: true)
477
+ #
398
478
  # @param [String] file
399
- # the path to a file
479
+ # the file to write to
480
+ # @param [Boolean] create
481
+ # whether or not to create missing path directories, false by default
482
+ # @param [Boolean] force
483
+ # whether or not to overwrite existing configuration file, false by default
484
+ # @param [String] format
485
+ # the format name for the configuration file, :auto by defualt
486
+ # @param [String] path
487
+ # the custom path to use to write a file to
488
+ #
489
+ # @raise [TTY::Config::WriteError]
400
490
  #
401
491
  # @api public
402
- def write(file = find_file, force: false, format: :auto)
403
- if file && ::File.exist?(file)
404
- if !force
405
- raise WriteError, "File `#{file}` already exists. " \
406
- 'Use :force option to overwrite.'
407
- elsif !::File.writable?(file)
408
- raise WriteError, "Cannot write to #{file}."
409
- end
410
- end
492
+ def write(file = find_file, create: false, force: false, format: :auto,
493
+ path: nil)
494
+ file = fullpath(file, path)
495
+ check_can_write(file, force)
411
496
 
412
- if file.nil?
413
- dir = @location_paths.empty? ? Dir.pwd : @location_paths.first
414
- file = ::File.join(dir, "#{filename}#{@extname}")
415
- end
497
+ set_file_metadata(file)
498
+ ext = (format == :auto ? extname : ".#{format}")
499
+ content = marshal(@settings, ext: ext)
500
+ filepath = Pathname.new(file)
416
501
 
417
- content = marshal(file, @settings, format: format)
418
- ::File.write(file, content)
502
+ create_missing_dirs(filepath, create)
503
+ ::File.write(filepath, content)
504
+ end
505
+
506
+ # Set file name and extension
507
+ #
508
+ # @example
509
+ # set_file_metadata("config.yml")
510
+ #
511
+ # @param [File] file
512
+ # the file to set metadata for
513
+ #
514
+ # @api public
515
+ def set_file_metadata(file)
516
+ self.extname = ::File.extname(file)
517
+ self.filename = ::File.basename(file, extname)
419
518
  end
420
519
 
421
520
  # Current configuration
@@ -433,7 +532,7 @@ module TTY
433
532
  # @api private
434
533
  def assert_either_value_or_block(value, block)
435
534
  if value.nil? && block.nil?
436
- raise ArgumentError, 'Need to set either value or block'
535
+ raise ArgumentError, "Need to set either value or block"
437
536
  elsif !(value.nil? || block.nil?)
438
537
  raise ArgumentError, "Can't set both value and block"
439
538
  end
@@ -453,7 +552,11 @@ module TTY
453
552
  # that will be performed at point when a new proc is invoked.
454
553
  #
455
554
  # @param [String] key
555
+ # the key to set validation for
456
556
  # @param [Proc] callback
557
+ # the callback to wrap
558
+ #
559
+ # @return [Proc]
457
560
  #
458
561
  # @api private
459
562
  def delay_validation(key, callback)
@@ -467,18 +570,14 @@ module TTY
467
570
  # Check if key passes all registered validations for a key
468
571
  #
469
572
  # @param [String] key
573
+ # the key to validate a value for
470
574
  # @param [Object] value
575
+ # the value to check
471
576
  #
472
577
  # @api private
473
578
  def assert_valid(key, value)
474
579
  validators[key].each do |validator|
475
- validator.call(key, value)
476
- end
477
- end
478
-
479
- def assert_keys_with_block(keys, block)
480
- if keys.size > 1 && block.nil?
481
- raise ArgumentError, 'Need to set env var in block'
580
+ validator.(key, value)
482
581
  end
483
582
  end
484
583
 
@@ -490,12 +589,16 @@ module TTY
490
589
  #
491
590
  # @param [Hash] settings
492
591
  #
493
- # @param [Array[Object]]
592
+ # @param [Array<Object>] keys
494
593
  # the keys to nest
495
594
  #
595
+ # @return [Hash]
596
+ # the nested setting
597
+ #
496
598
  # @api private
497
599
  def deep_set(settings, *keys)
498
600
  return settings if keys.empty?
601
+
499
602
  key, *rest = *keys
500
603
  value = settings[key]
501
604
 
@@ -510,15 +613,13 @@ module TTY
510
613
  end
511
614
  end
512
615
 
513
- def deep_find(settings, key, found = nil)
514
- if settings.respond_to?(:key?) && settings.key?(key)
515
- settings[key]
516
- elsif settings.is_a?(Enumerable)
517
- settings.each { |obj| found = deep_find(obj, key) }
518
- found
519
- end
520
- end
521
-
616
+ # Convert key to an array of key elements
617
+ #
618
+ # @param [String, Array<String, Symbol>] keys
619
+ #
620
+ # @return [Array<String>]
621
+ #
622
+ # @api private
522
623
  def convert_to_keys(keys)
523
624
  first_key = keys[0]
524
625
  if first_key.to_s.include?(key_delim)
@@ -528,6 +629,18 @@ module TTY
528
629
  end
529
630
  end
530
631
 
632
+ # Convert nested key from an array to a string
633
+ #
634
+ # @example
635
+ # flatten_keys(%i[foo bar baz]) # => "foo.bar.baz"
636
+ #
637
+ # @param [Array<String, Symbol>] keys
638
+ # the nested key to convert
639
+ #
640
+ # @return [String]
641
+ # the delimited nested key
642
+ #
643
+ # @api private
531
644
  def flatten_keys(keys)
532
645
  first_key = keys[0]
533
646
  if first_key.to_s.include?(key_delim)
@@ -540,8 +653,12 @@ module TTY
540
653
  # Fetch value under deeply nested keys with indiffernt key access
541
654
  #
542
655
  # @param [Hash] settings
656
+ # the settings to search
657
+ # @param [Array<Object>] keys
658
+ # the nested key to look up
543
659
  #
544
- # @param [Array[Object]] keys
660
+ # @return [Object, nil]
661
+ # the value or nil
545
662
  #
546
663
  # @api private
547
664
  def deep_fetch(settings, *keys)
@@ -554,6 +671,14 @@ module TTY
554
671
  end
555
672
  end
556
673
 
674
+ # Merge two deeply nested hash objects
675
+ #
676
+ # @param [Hash] this_hash
677
+ # @param [Hash] other_hash
678
+ #
679
+ # @return [Hash]
680
+ # the merged hash object
681
+ #
557
682
  # @api private
558
683
  def deep_merge(this_hash, other_hash, &block)
559
684
  this_hash.merge(other_hash) do |key, this_val, other_val|
@@ -567,21 +692,41 @@ module TTY
567
692
  end
568
693
  end
569
694
 
695
+ # Delete a deeply nested key
696
+ #
697
+ # @param [Array<String>] keys
698
+ # the nested key to delete
699
+ # @param [Hash{String => Object}]
700
+ # the settings to delete key from
701
+ #
702
+ # @return [Object]
703
+ # the deleted object(s)
704
+ #
570
705
  # @api private
571
- def deep_delete(*keys, settings)
706
+ def deep_delete(*keys, settings, &default)
572
707
  key, *rest = keys
573
708
  value = settings[key]
574
- if !value.nil? && value.is_a?(::Hash)
575
- deep_delete(*rest, value)
709
+ if !rest.empty? && value.is_a?(::Hash)
710
+ deep_delete(*rest, value, &default)
576
711
  elsif !value.nil?
577
712
  settings.delete(key)
713
+ elsif default
714
+ default.(key)
578
715
  end
579
716
  end
580
717
 
718
+ # Search for a configuration file in a path
719
+ #
720
+ # @param [String] path
721
+ # the path to search
722
+ #
723
+ # @return [String, nil]
724
+ # the configuration file path or nil
725
+ #
581
726
  # @api private
582
727
  def search_in_path(path)
583
728
  path = Pathname.new(path)
584
- @extensions.each do |ext|
729
+ extensions.each do |ext|
585
730
  if ::File.exist?(path.join("#{filename}#{ext}").to_s)
586
731
  return path.join("#{filename}#{ext}").to_s
587
732
  end
@@ -589,81 +734,120 @@ module TTY
589
734
  nil
590
735
  end
591
736
 
737
+ # Create a full path to a configuration file
738
+ #
739
+ # @param [String] file
740
+ # the configuration file
741
+ # @param [String] path
742
+ # the path to configuration file
743
+ #
744
+ # @return [String]
745
+ # the full path to a file
746
+ #
747
+ # @api private
748
+ def fullpath(file, path)
749
+ if file.nil?
750
+ dir = path || @location_paths.first || Dir.pwd
751
+ ::File.join(dir, "#{filename}#{@extname}")
752
+ elsif file && path
753
+ ::File.join(path, ::File.basename(file))
754
+ else
755
+ file
756
+ end
757
+ end
758
+
759
+ # Check if a file can be written to
760
+ #
761
+ # @param [String] file
762
+ # the configuration file
763
+ # @param [Boolean] force
764
+ # whether or not to force writing
765
+ #
766
+ # @raise [TTY::Config::WriteError]
767
+ #
768
+ # @return [nil]
769
+ #
592
770
  # @api private
593
- def unmarshal(file, format: :auto)
594
- file_ext = ::File.extname(file)
595
- ext = (format == :auto ? file_ext : ".#{format}")
596
- self.extname = file_ext
597
- self.filename = ::File.basename(file, file_ext)
598
-
599
- case ext
600
- when *EXTENSIONS[:yaml]
601
- load_read_dep('yaml', ext)
602
- if YAML.respond_to?(:safe_load)
603
- YAML.safe_load(::File.read(file))
604
- else
605
- YAML.load(::File.read(file))
606
- end
607
- when *EXTENSIONS[:json]
608
- load_read_dep('json', ext)
609
- JSON.parse(::File.read(file))
610
- when *EXTENSIONS[:toml]
611
- load_read_dep('toml', ext)
612
- TOML.load(::File.read(file))
613
- when *EXTENSIONS[:ini]
614
- load_read_dep('inifile', ext)
615
- ini = IniFile.load(file).to_h
616
- global = ini.delete('global')
617
- ini.merge!(global)
771
+ def check_can_write(file, force)
772
+ return unless file && ::File.exist?(file)
773
+
774
+ if !force
775
+ raise WriteError, "File `#{file}` already exists. " \
776
+ "Use :force option to overwrite."
777
+ elsif !::File.writable?(file)
778
+ raise WriteError, "Cannot write to #{file}."
779
+ end
780
+ end
781
+
782
+ # Create any missing directories
783
+ #
784
+ # @param [Pathname] filepath
785
+ # the file path
786
+ # @param [Boolean] create
787
+ # whether or not to create missing directories
788
+ #
789
+ # @raise [TTY::Config::WriteError]
790
+ #
791
+ # @return [nil]
792
+ #
793
+ # @api private
794
+ def create_missing_dirs(filepath, create)
795
+ if !filepath.dirname.exist? && !create
796
+ raise WriteError, "Directory `#{filepath.dirname}` doesn't exist. " \
797
+ "Use :create option to create missing directories."
618
798
  else
619
- raise ReadError, "Config file format `#{ext}` is not supported."
799
+ filepath.dirname.mkpath
620
800
  end
621
801
  end
622
802
 
623
- # Try loading read dependency
803
+ # Crate a marshaller instance based on the extension name
804
+ #
805
+ # @param [String] ext
806
+ # the extension name
807
+ #
808
+ # @return [nil, Marshaller]
809
+ #
624
810
  # @api private
625
- def load_read_dep(gem_name, format)
626
- require gem_name
627
- rescue LoadError
628
- raise ReadError, "Gem `#{gem_name}` is missing. Please install it " \
629
- "to read #{format} configuration format."
811
+ def create_marshaller(ext)
812
+ marshaller = marshallers.find { |marsh| marsh.ext.include?(ext) }
813
+
814
+ return nil if marshaller.nil?
815
+
816
+ marshaller.new
630
817
  end
631
818
 
632
- # Marshal data hash into a configuration file content
819
+ # Unmarshal content into a hash object
633
820
  #
634
- # @return [String]
821
+ # @param [String] content
822
+ # the content to convert into a hash object
823
+ #
824
+ # @return [Hash{String => Object}]
635
825
  #
636
826
  # @api private
637
- def marshal(file, data, format: :auto)
638
- file_ext = ::File.extname(file)
639
- ext = (format == :auto ? file_ext : ".#{format}")
640
- self.extname = file_ext
641
- self.filename = ::File.basename(file, file_ext)
642
-
643
- case ext
644
- when *EXTENSIONS[:yaml]
645
- load_write_dep('yaml', ext)
646
- YAML.dump(self.class.normalize_hash(data, :to_s))
647
- when *EXTENSIONS[:json]
648
- load_write_dep('json', ext)
649
- JSON.pretty_generate(data)
650
- when *EXTENSIONS[:toml]
651
- load_write_dep('toml', ext)
652
- TOML::Generator.new(data).body
653
- when *EXTENSIONS[:ini]
654
- Config.generate(data)
827
+ def unmarshal(content, ext: nil)
828
+ ext ||= extname
829
+ if marshaller = create_marshaller(ext)
830
+ marshaller.unmarshal(content)
655
831
  else
656
- raise WriteError, "Config file format `#{ext}` is not supported."
832
+ raise ReadError, "Config file format `#{ext}` is not supported."
657
833
  end
658
834
  end
659
835
 
660
- # Try loading write depedency
836
+ # Marshal hash object into a configuration file content
837
+ #
838
+ # @param [Hash{String => Object}] object
839
+ # the object to convert to string
840
+ #
841
+ # @return [String]
842
+ #
661
843
  # @api private
662
- def load_write_dep(gem_name, format)
663
- require gem_name
664
- rescue LoadError
665
- raise WriteError, "Gem `#{gem_name}` is missing. Please install it " \
666
- "to read #{format} configuration format."
844
+ def marshal(object, ext: nil)
845
+ ext ||= extname
846
+ if marshaller = create_marshaller(ext)
847
+ marshaller.marshal(object)
848
+ else
849
+ raise WriteError, "Config file format `#{ext}` is not supported."
850
+ end
667
851
  end
668
852
  end # Config
669
853
  end # TTY