messages_dictionary 2.0.0 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 270aca02bdc13e851f345d7ce783fbd8329e95f2054c3865fa5b5c1cc2b09c6c
4
- data.tar.gz: be5c5615e7c87d7423019fcd96afdf6695ea8527b7c63777c550ca8f6c4fdde7
3
+ metadata.gz: 3a6f7d3a4a63be59119844b7d3139f10b82118a7a726cbfb59f078dc68edb916
4
+ data.tar.gz: 1884503b8aad4f2c452666833a7a48867c2c0fa55c74854e94ea0a5a6c0bed79
5
5
  SHA512:
6
- metadata.gz: 9661d7b64b05ee3d68042938cf05f56b4606107104122e087b39ee86c0b45db79e6ec304e89785d53c1565e7f18a200e542f3ef0cff81acf94afd1cae31aa222
7
- data.tar.gz: e401f904c1e3efa0215a0551a33f4dced1a915f7173705232aee7f660c21f04b0f4f6853b2b280a167183d9e8c2960d6287ad0f156f7f54e967f85eba88d2781
6
+ metadata.gz: 2979cbdfacfdfdd47985304f3d6535ce4fe121ca240807e1700a8f9a15aa54e4301e487eeddbcaf9f456f4814f0f51d881e026cdafd65a23ce96d3c539b29057
7
+ data.tar.gz: eb7c8163836c469604d4fb1af38c84fc9c4d3a450dba73cb429e018a741e55768b0b25fa4f6be2715157e9ec794daf31bd21505db0179f9fe84f23b75cd289d6
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 2.0.0
3
+ ## 2.1.0 (22-Nov-2022)
4
+
5
+ * `pou` and `pretty_output` are now available inside class methods
6
+ * `DICTIONARY_CONF` now contains an instance of the `Config` class that takes care of all configuration options
7
+ * Added `lazy` option that enables lazy loading
8
+ * Added `on_key_missing` option which is set to `:raise` by default. You can pass a proc or a lambda to this option in order to provide a custom handler that fires when a given key cannot be found.
9
+ * Added `file_loader` option to handle custom file loading
10
+
11
+ ## 2.0.0 (21-Nov-2022)
4
12
 
5
13
  This is a major re-write of the gem. All core features stay the same and there should not be any breaking changes, except for one thing: you should not use "destructive" methods when transforming your messages.
6
14
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- messages_dictionary (2.0.0)
4
+ messages_dictionary (2.1.0)
5
5
  hashie (~> 5.0)
6
6
  zeitwerk (~> 2.4)
7
7
 
@@ -70,6 +70,7 @@ PLATFORMS
70
70
 
71
71
  DEPENDENCIES
72
72
  codecov (~> 0.1)
73
+ json (~> 2)
73
74
  messages_dictionary!
74
75
  rake (~> 13.0)
75
76
  rspec (~> 3.6)
data/README.md CHANGED
@@ -35,9 +35,7 @@ Another, a bit more complex, use case in the [lessons_indexer gem](https://githu
35
35
  * [Other classes simply inherit from it](https://github.com/bodrovis/lessons_indexer/blob/master/lib/lessons_indexer/indexer.rb#L2)
36
36
  * [Messages are fetched easily](https://github.com/bodrovis/lessons_indexer/blob/master/lib/lessons_indexer/indexer.rb#L45)
37
37
 
38
- ## Usage
39
-
40
- ### Basic Example
38
+ ## Basic usage
41
39
 
42
40
  Suppose you have the following program:
43
41
 
@@ -86,7 +84,7 @@ class MyOtherClass
86
84
  def greet
87
85
  pretty_output(:welcome)
88
86
  # Or simply
89
- pou(:welcome)
87
+ pou :welcome
90
88
  end
91
89
  end
92
90
  ```
@@ -156,9 +154,9 @@ class MyClass
156
154
  end
157
155
  ```
158
156
 
159
- ### Further Customization
157
+ ## Further Customization
160
158
 
161
- #### Specifying File Name and Directory
159
+ ### Specifying File Name and Directory
162
160
 
163
161
  By default `messages_dictionary` will search for a *.yml* file named after your class (converted to snake case,
164
162
  so for the `MyClass` the file should be named *my_class.yml*)
@@ -170,13 +168,29 @@ inside the same directory. However, this behavior can be easily changed with the
170
168
  ```ruby
171
169
  class MyClass
172
170
  include MessagesDictionary
173
- has_messages_dictionary file: 'some_file.yml', dir: 'C:\my_docs'
171
+ has_messages_dictionary file: 'some_file.yml', dir: 'my_docs'
174
172
  end
175
173
  ```
176
174
 
177
175
  Both of these options are not mandatory.
178
176
 
179
- #### Specifying Messages Hash
177
+ ### Providing a custom file loader
178
+
179
+ By default the gem a messages file in YAML format. However, you might want to use a different format: for example, JSON. In this case you'll have to provide a custom loader:
180
+
181
+ ```ruby
182
+ class MyClass
183
+ include MessagesDictionary
184
+ has_messages_dictionary file: 'test_file.json', dir: 'my_dir',
185
+ file_loader: ->(file_path) { JSON.parse(File.read(file_path)) }
186
+ end
187
+ ```
188
+
189
+ The `:file_loader` option accepts a proc or a lambda that receives a path to your messages file as an argument. This lambda must return a hash object with keys and the corresponding values.
190
+
191
+ The default value for the `:file_loader` is `->(f) { YAML.load_file(f) }`.
192
+
193
+ ### Specifying Messages Hash
180
194
 
181
195
  Instead of loading messages from a file, you can pass hash to the `has_messages_dictionary` using `:messages` option:
182
196
 
@@ -189,7 +203,7 @@ end
189
203
 
190
204
  Nesting and all other features are supported as well.
191
205
 
192
- #### Specifying Output and Display Method
206
+ ### Specifying Output and Display Method
193
207
 
194
208
  By default all messages will be outputted to `STDOUT` using `puts` method, however this can be changed:
195
209
 
@@ -204,7 +218,32 @@ class MyClass
204
218
  end
205
219
  ```
206
220
 
207
- #### Providing Custom Transformation Logic
221
+ ### "Lazy" mode
222
+
223
+ By default this gem will load all messages from the given file. However, you can enable a "lazy" mode so that messages are not loaded until `pou` or `pretty_output` methods have been called. The "lazy" mode can only be enabled when the `:file` option is provided (in other words, `:lazy` has no effect with the `:messages` setting):
224
+
225
+ ```ruby
226
+ class MyClass
227
+ include MessagesDictionary
228
+ has_messages_dictionary lazy: true, file: 'my_file.yml'
229
+
230
+ def greet
231
+ pou :hi
232
+ end
233
+ end
234
+
235
+ # At this point no messages are loaded from the given file
236
+
237
+ obj = MyClass.new
238
+
239
+ # ... doing some other stuff ...
240
+
241
+ # Messages are still not loaded at this point!
242
+
243
+ obj.greet # Now all messages will be loaded from the YAML file
244
+ ```
245
+
246
+ ### Providing Custom Transformation Logic
208
247
 
209
248
  Suppose you want to transform your message somehow or even simply return it instead of printing on the screen.
210
249
  `pretty_output` method accepts an optional block for this purpose:
@@ -262,6 +301,45 @@ def greet
262
301
  end
263
302
  ```
264
303
 
304
+ ### Handling missing keys
305
+
306
+ By default when a non-existent key is requested, an error will be raised:
307
+
308
+ ```ruby
309
+ class MyClass
310
+ include MessagesDictionary
311
+ has_messages_dictionary messages: {key: 'value'}
312
+
313
+ def greet
314
+ pou :unknown_key # trying to use some unknown key...
315
+ end
316
+ end
317
+
318
+ obj = MyClass.new
319
+
320
+ obj.greet # KeyError is raised here!
321
+ ```
322
+
323
+ However, you can adjust the `:on_key_missing` option and provide a custom proc or lambda to handle all missing keys:
324
+
325
+ ```ruby
326
+ class MyClass
327
+ include MessagesDictionary
328
+ has_messages_dictionary messages: {key: 'value'},
329
+ on_key_missing: ->(key) { key } # We simply return the requested key itself
330
+
331
+ def greet
332
+ pou :unknown_key
333
+ end
334
+ end
335
+
336
+ obj = MyClass.new
337
+
338
+ obj.greet # Prints "unknown_key" to the screen, no errors will be raised
339
+ ```
340
+
341
+ So, in the example above we simply return the key itself if it was not found in the messages hash.
342
+
265
343
  ## License
266
344
 
267
345
  Licensed under the [MIT License](https://github.com/bodrovis-learning/messages_dictionary/blob/master/LICENSE).
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessagesDictionary
4
+ # This class contains all configuration params
5
+ class Config
6
+ using MessagesDictionary::Utils::StringUtils
7
+
8
+ attr_reader :msgs, :output_target, :output_method, :transform, :file, :on_key_missing, :file_loader
9
+
10
+ def initialize(opts, klass)
11
+ @__klass = klass
12
+ @__lazy = opts[:lazy]
13
+
14
+ prepare_storage_for opts
15
+
16
+ @on_key_missing = opts[:on_key_missing] || :raise
17
+ @output_target = opts[:output] || $stdout
18
+ @output_method = (opts[:method] || :puts).to_sym
19
+ @transform = opts[:transform]
20
+
21
+ load_messages
22
+ end
23
+
24
+ # This method loads messages from a file but respects the "lazy" option.
25
+ # In other words, it does not load anything if "lazy" is "true".
26
+ # To force messages loading, use the load_messages! method instead.
27
+ def load_messages
28
+ return if @__lazy
29
+
30
+ do_load_messages!
31
+ end
32
+
33
+ # Loads messages from the file even if "lazy" is "true"
34
+ def load_messages!
35
+ do_load_messages!
36
+ end
37
+
38
+ private
39
+
40
+ def do_load_messages!
41
+ return if @__storage == :hash || @__loaded
42
+
43
+ begin
44
+ data = @file_loader.call @file
45
+ rescue Errno::ENOENT
46
+ raise Errno::ENOENT, "File #{@file} does not exist..."
47
+ else
48
+ @msgs = MessagesDictionary::Utils::Dict.new data
49
+ @__loaded = true
50
+ end
51
+ end
52
+
53
+ def prepare_storage_for(opts)
54
+ if opts.key?(:messages)
55
+ @__storage = :hash
56
+ @msgs = MessagesDictionary::Utils::Dict.new opts[:messages]
57
+ @__loaded = true
58
+ else
59
+ @__storage = :file
60
+ @file = opts[:file] || "#{@__klass.name.nil? ? 'unknown' : @__klass.name.snakecase}.yml"
61
+ @file = File.expand_path(@file, opts[:dir] || '.')
62
+ @file_loader = opts[:file_loader] || ->(f) { YAML.load_file(f) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -3,11 +3,10 @@
3
3
  module MessagesDictionary
4
4
  # Main module that injects all the necessary methods in the target class
5
5
  module Injector
6
- using MessagesDictionary::Utils::StringUtils
7
-
8
6
  def self.included(klass)
9
7
  klass.extend ClassMethods
10
8
  klass.include InstanceMethods
9
+ klass.extend InstanceMethods
11
10
  end
12
11
 
13
12
  # Class methods to be defined in the target (where the module was included)
@@ -17,27 +16,8 @@ module MessagesDictionary
17
16
  # with all the necesary goodies
18
17
  def has_messages_dictionary(opts = {})
19
18
  # rubocop:enable Naming/PredicateName
20
- messages = MessagesDictionary::Utils::Dict.new(
21
- opts.fetch(:messages) { __from_file(opts) }
22
- )
23
-
24
- const_set(:DICTIONARY_CONF, {msgs: messages,
25
- output: opts[:output] || $stdout,
26
- method: opts[:method] || :puts,
27
- transform: opts[:transform]})
28
- end
29
-
30
- private
31
-
32
- def __from_file(opts)
33
- file = opts[:file] || "#{name.nil? ? 'unknown' : name.snakecase}.yml"
34
- file = File.expand_path(file, opts[:dir]) if opts[:dir]
35
19
 
36
- begin
37
- YAML.load_file(file)
38
- rescue Errno::ENOENT
39
- abort "File #{file} does not exist..."
40
- end
20
+ const_set :DICTIONARY_CONF, MessagesDictionary::Config.new(opts, self)
41
21
  end
42
22
  end
43
23
 
@@ -46,8 +26,10 @@ module MessagesDictionary
46
26
  # This method will output your messages, perform interpolation,
47
27
  # and transformations
48
28
  def pretty_output(key, values = {}, &block)
49
- msg = self.class::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do
50
- raise KeyError, "#{key} cannot be found in the provided file..."
29
+ __config.load_messages!
30
+
31
+ msg = __config.msgs.deep_fetch(*key.to_s.split('.')) do
32
+ handle_key_missing(key)
51
33
  end
52
34
 
53
35
  __process(
@@ -61,6 +43,16 @@ module MessagesDictionary
61
43
 
62
44
  private
63
45
 
46
+ def handle_key_missing(key)
47
+ raise KeyError, "#{key} cannot be found..." if __config.on_key_missing == :raise
48
+
49
+ __config.on_key_missing.call(key)
50
+ end
51
+
52
+ def __config
53
+ @__config ||= respond_to?(:const_get) ? const_get(:DICTIONARY_CONF) : self.class.const_get(:DICTIONARY_CONF)
54
+ end
55
+
64
56
  def __replace(msg, values)
65
57
  values.each do |k, v|
66
58
  msg.gsub!(Regexp.new("\\{\\{#{k}\\}\\}"), v.to_s)
@@ -70,12 +62,12 @@ module MessagesDictionary
70
62
  end
71
63
 
72
64
  def __process(msg, &block)
73
- transform = block || self.class::DICTIONARY_CONF[:transform]
65
+ transform = block || __config.transform
74
66
 
75
67
  if transform
76
68
  transform.call(msg)
77
69
  else
78
- self.class::DICTIONARY_CONF[:output].send(self.class::DICTIONARY_CONF[:method].to_sym, msg)
70
+ __config.output_target.send(__config.output_method, msg)
79
71
  end
80
72
  end
81
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MessagesDictionary
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency 'zeitwerk', '~> 2.4'
25
25
 
26
26
  spec.add_development_dependency 'codecov', '~> 0.1'
27
+ spec.add_development_dependency 'json', '~> 2'
27
28
  spec.add_development_dependency 'rake', '~> 13.0'
28
29
  spec.add_development_dependency 'rspec', '~> 3.6'
29
30
  spec.add_development_dependency 'rubocop', '~> 1.6'
@@ -1,16 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  RSpec.describe MessagesDictionary::Injector do
4
6
  let(:dummy) { Class.new { include MessagesDictionary::Injector } }
5
7
 
6
8
  context 'with messages' do
9
+ context 'when lazy-loading' do
10
+ it 'loads messages on demand' do
11
+ in_dir 'my_test_file.yml', 'my_test_dir' do
12
+ dummy.class_eval do
13
+ has_messages_dictionary file: 'my_test_file.yml', dir: 'my_test_dir', lazy: true
14
+ end
15
+
16
+ expect(dummy::DICTIONARY_CONF.instance_variable_get(:@__loaded)).to be_falsey
17
+ expect(dummy::DICTIONARY_CONF.msgs).to be_nil
18
+
19
+ object = dummy.new
20
+ expect(object.send(:pretty_output, :test) { |msg| msg }).to eq('string')
21
+ expect(object.send(:pretty_output, :interpolated, a: 42) { |msg| msg }).to eq('Value is 42')
22
+
23
+ expect(dummy::DICTIONARY_CONF.instance_variable_get(:@__loaded)).to be true
24
+ expect(dummy::DICTIONARY_CONF.msgs.keys).to include('test', 'interpolated')
25
+ end
26
+ end
27
+ end
28
+
7
29
  context 'when outputting' do
8
30
  it 'uses STDOUT by default' do
9
31
  dummy.class_eval do
10
32
  has_messages_dictionary messages: {test: 'string'}
11
33
  end
12
34
 
13
- expect(dummy::DICTIONARY_CONF[:output]).to eq($stdout)
35
+ expect(dummy::DICTIONARY_CONF.output_target).to eq($stdout)
14
36
  end
15
37
 
16
38
  it 'uses puts method by default' do
@@ -18,7 +40,7 @@ RSpec.describe MessagesDictionary::Injector do
18
40
  has_messages_dictionary messages: {test: 'string'}
19
41
  end
20
42
 
21
- expect(dummy::DICTIONARY_CONF[:method]).to eq(:puts)
43
+ expect(dummy::DICTIONARY_CONF.output_method).to eq(:puts)
22
44
  end
23
45
 
24
46
  it 'allows customizing output and method' do
@@ -52,6 +74,22 @@ RSpec.describe MessagesDictionary::Injector do
52
74
 
53
75
  expect(output).to have_received(:puts).with('string').exactly(1).time
54
76
  end
77
+
78
+ it 'works for class methods' do
79
+ dummy.class_eval do
80
+ has_messages_dictionary messages: {test: 'string'}
81
+
82
+ define_singleton_method :run_klass do
83
+ pou(:test)
84
+ end
85
+ end
86
+
87
+ allow($stdout).to receive(:puts).with('string')
88
+
89
+ dummy.run_klass
90
+
91
+ expect($stdout).to have_received(:puts).exactly(1).time
92
+ end
55
93
  end
56
94
 
57
95
  context 'when passed as hash' do
@@ -60,6 +98,7 @@ RSpec.describe MessagesDictionary::Injector do
60
98
  has_messages_dictionary messages: {parent: {child: 'child_string'}}
61
99
  end
62
100
 
101
+ expect(dummy::DICTIONARY_CONF.instance_variable_get(:@__loaded)).to be true
63
102
  object = dummy.new
64
103
  expect(object.send(:pretty_output, 'parent.child') { |msg| msg }).to eq('child_string')
65
104
  end
@@ -101,6 +140,19 @@ RSpec.describe MessagesDictionary::Injector do
101
140
  expect(object.send(:pretty_output, :interpolated, a: 42) { |msg| msg }).to eq('Value is 42')
102
141
  end
103
142
  end
143
+
144
+ it 'allows providing a custom file loader' do
145
+ in_dir 'test_file.json', 'my_dir' do
146
+ dummy.class_eval do
147
+ has_messages_dictionary file: 'test_file.json', dir: 'my_dir',
148
+ file_loader: ->(f) { JSON.parse(File.read(f)) }
149
+ end
150
+
151
+ object = dummy.new
152
+ expect(object.send(:pretty_output, 'nested.test') { |msg| msg }).to eq('string')
153
+ expect(object.send(:pretty_output, :interpolated, a: 42) { |msg| msg }).to eq('Value is 42')
154
+ end
155
+ end
104
156
  end
105
157
  end
106
158
 
@@ -110,8 +162,28 @@ RSpec.describe MessagesDictionary::Injector do
110
162
  has_messages_dictionary messages: {test: 'string'}
111
163
  end
112
164
 
165
+ key = :does_not_exist
166
+
167
+ object = dummy.new
168
+ expect { object.send(:pretty_output, key) }.to raise_error(
169
+ an_instance_of(KeyError).
170
+ and(
171
+ having_attributes(message: "#{key} cannot be found...")
172
+ )
173
+ )
174
+ end
175
+
176
+ it 'is allows to use custom missing key handler' do
177
+ dummy.class_eval do
178
+ has_messages_dictionary messages: {test: 'string'},
179
+ on_key_missing: ->(key) { key.upcase.split('.') },
180
+ transform: ->(msg) { msg }
181
+ end
182
+
183
+ key = 'does.not.exist'
184
+
113
185
  object = dummy.new
114
- expect { object.send(:pretty_output, :does_not_exist) }.to raise_error(KeyError)
186
+ expect(object.send(:pou, key)).to eq(%w[DOES NOT EXIST])
115
187
  end
116
188
 
117
189
  it 'is raised when file is not found and the program aborts' do
@@ -120,8 +192,12 @@ RSpec.describe MessagesDictionary::Injector do
120
192
  has_messages_dictionary dir: 'random', file: 'not_exist.yml'
121
193
  end
122
194
  end.to raise_error(
123
- an_instance_of(SystemExit).
124
- and(having_attributes(message: "File #{File.expand_path('random/not_exist.yml')} does not exist..."))
195
+ an_instance_of(Errno::ENOENT).
196
+ and(having_attributes(
197
+ message: "No such file or directory - File #{File.expand_path(
198
+ 'random/not_exist.yml'
199
+ )} does not exist..."
200
+ ))
125
201
  )
126
202
  end
127
203
  end
@@ -8,12 +8,38 @@ module SpecFilesSetup
8
8
 
9
9
  FileUtils.mkdir_p full_path
10
10
 
11
- f = File.new(File.join(full_path, file), 'w+')
12
- f.write("test: string\ninterpolated: Value is {{a}}")
13
- f.close
11
+ File.open(File.join(full_path, file), 'w+') do |f|
12
+ if /\.ya?ml\z/i.match?(file)
13
+ f.write yaml_data
14
+ else
15
+ f.write json_data
16
+ end
17
+ end
14
18
  end
15
19
 
16
20
  def clear_env!(path)
17
21
  FileUtils.remove_entry("#{RSPEC_ROOT}/dummy/#{path}")
22
+ rescue Errno::EACCES
23
+ puts "Cannot remove #{path}"
24
+ end
25
+
26
+ private
27
+
28
+ def json_data
29
+ <<~DATA
30
+ {
31
+ "nested": {
32
+ "test": "string"
33
+ },
34
+ "interpolated": "Value is {{a}}"
35
+ }
36
+ DATA
37
+ end
38
+
39
+ def yaml_data
40
+ <<~DATA
41
+ test: string
42
+ interpolated: Value is {{a}}
43
+ DATA
18
44
  end
19
45
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: messages_dictionary
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ilya Krukowski
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-11-21 00:00:00.000000000 Z
12
+ date: 2022-11-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: hashie
@@ -53,6 +53,20 @@ dependencies:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0.1'
56
+ - !ruby/object:Gem::Dependency
57
+ name: json
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '2'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '2'
56
70
  - !ruby/object:Gem::Dependency
57
71
  name: rake
58
72
  requirement: !ruby/object:Gem::Requirement
@@ -179,6 +193,7 @@ files:
179
193
  - README.md
180
194
  - Rakefile
181
195
  - lib/messages_dictionary.rb
196
+ - lib/messages_dictionary/config.rb
182
197
  - lib/messages_dictionary/injector.rb
183
198
  - lib/messages_dictionary/utils/dict.rb
184
199
  - lib/messages_dictionary/utils/string_utils.rb