tty-config 0.3.1 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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