localio 0.1.7 → 0.2.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 (54) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +28 -0
  3. data/.gitignore +4 -1
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile.lock +134 -0
  7. data/README.md +36 -34
  8. data/bin/localize +10 -7
  9. data/docs/plans/2026-02-23-modernization-design.md +91 -0
  10. data/docs/plans/2026-02-23-modernization.md +1699 -0
  11. data/docs/plans/2026-02-23-twine-writer-design.md +72 -0
  12. data/docs/plans/2026-02-23-twine-writer.md +267 -0
  13. data/lib/localio/localizable_writer.rb +4 -1
  14. data/lib/localio/processors/csv_processor.rb +1 -1
  15. data/lib/localio/processors/google_drive_processor.rb +19 -45
  16. data/lib/localio/processors/xlsx_processor.rb +1 -1
  17. data/lib/localio/template_handler.rb +3 -1
  18. data/lib/localio/templates/android_localizable.erb +14 -2
  19. data/lib/localio/templates/ios_constant_localizable.erb +16 -2
  20. data/lib/localio/templates/ios_localizable.erb +20 -5
  21. data/lib/localio/templates/java_properties_localizable.erb +16 -2
  22. data/lib/localio/templates/json_localizable.erb +6 -5
  23. data/lib/localio/templates/rails_localizable.erb +15 -3
  24. data/lib/localio/templates/resx_localizable.erb +14 -2
  25. data/lib/localio/templates/swift_constant_localizable.erb +15 -2
  26. data/lib/localio/version.rb +1 -1
  27. data/lib/localio/writers/ios_writer.rb +3 -3
  28. data/lib/localio/writers/swift_writer.rb +3 -3
  29. data/lib/localio/writers/twine_writer.rb +48 -0
  30. data/localio.gemspec +19 -25
  31. data/spec/fixtures/sample.csv +11 -0
  32. data/spec/localio/filter_spec.rb +40 -0
  33. data/spec/localio/formatter_spec.rb +32 -0
  34. data/spec/localio/processors/csv_processor_spec.rb +89 -0
  35. data/spec/localio/processors/google_drive_processor_spec.rb +107 -0
  36. data/spec/localio/processors/xls_processor_spec.rb +65 -0
  37. data/spec/localio/processors/xlsx_processor_spec.rb +59 -0
  38. data/spec/localio/segment_spec.rb +27 -0
  39. data/spec/localio/segments_list_holder_spec.rb +22 -0
  40. data/spec/localio/string_helper_spec.rb +49 -0
  41. data/spec/localio/template_handler_spec.rb +67 -0
  42. data/spec/localio/term_spec.rb +24 -0
  43. data/spec/localio/writers/android_writer_spec.rb +71 -0
  44. data/spec/localio/writers/ios_writer_spec.rb +63 -0
  45. data/spec/localio/writers/java_properties_writer_spec.rb +35 -0
  46. data/spec/localio/writers/json_writer_spec.rb +57 -0
  47. data/spec/localio/writers/rails_writer_spec.rb +47 -0
  48. data/spec/localio/writers/resx_writer_spec.rb +44 -0
  49. data/spec/localio/writers/swift_writer_spec.rb +42 -0
  50. data/spec/localio/writers/twine_writer_spec.rb +68 -0
  51. data/spec/localio_spec.rb +62 -0
  52. data/spec/spec_helper.rb +24 -0
  53. data/spec/support/shared_terms.rb +35 -0
  54. metadata +61 -46
@@ -0,0 +1,1699 @@
1
+ # Localio Modernization: Test Suite + Dependency Updates
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add comprehensive RSpec test coverage to the Localio gem, then update all dependencies to support Ruby 3.x.
6
+
7
+ **Architecture:** Phase 1 writes tests against the existing codebase using fixtures and mocks. Phase 2 updates dependencies using the test suite as a safety net. All writer tests use `Dir.mktmpdir` + `Dir.chdir` for file isolation. External services (Google Drive, XLSX/XLS readers) are mocked at the library interface level.
8
+
9
+ **Tech Stack:** Ruby 3.x, RSpec 3.x, built-in CSV, mocked google_drive/spreadsheet/simple_xlsx_reader
10
+
11
+ ---
12
+
13
+ ## Phase 1: Test Suite
14
+
15
+ ### Task 1: Bootstrap RSpec
16
+
17
+ **Files:**
18
+ - Create: `spec/spec_helper.rb`
19
+ - Create: `.rspec`
20
+
21
+ **Step 1: Create `.rspec`**
22
+
23
+ ```
24
+ --require spec_helper
25
+ --format documentation
26
+ --color
27
+ ```
28
+
29
+ **Step 2: Create `spec/spec_helper.rb`**
30
+
31
+ ```ruby
32
+ require 'tmpdir'
33
+ require 'fileutils'
34
+
35
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
36
+
37
+ Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f }
38
+
39
+ RSpec.configure do |config|
40
+ config.expect_with :rspec do |c|
41
+ c.include_chain_clauses_in_custom_matcher_descriptions = true
42
+ end
43
+ config.mock_with :rspec do |mocks|
44
+ mocks.verify_partial_doubles = true
45
+ end
46
+ config.shared_context_metadata_behavior = :apply_to_host_groups
47
+ config.filter_run_when_matching :focus
48
+ config.disable_monkey_patching!
49
+ config.order = :random
50
+ end
51
+ ```
52
+
53
+ **Step 3: Run RSpec**
54
+
55
+ Run: `bundle exec rspec`
56
+ Expected: `0 examples, 0 failures`
57
+
58
+ **Step 4: Commit**
59
+
60
+ ```bash
61
+ git add spec/spec_helper.rb .rspec
62
+ git commit -m "test: bootstrap RSpec with spec_helper"
63
+ ```
64
+
65
+ ---
66
+
67
+ ### Task 2: Create fixture CSV and shared terms
68
+
69
+ **Files:**
70
+ - Create: `spec/fixtures/sample.csv`
71
+ - Create: `spec/support/shared_terms.rb`
72
+
73
+ **Step 1: Create `spec/fixtures/sample.csv`**
74
+
75
+ ```
76
+ Title Row,,,
77
+ [key],*en,es,fr
78
+ [comment],Section General,Section General,Section General
79
+ app_name,My App,Mi Aplicación,Mon Application
80
+ greeting,Hello %@ world,Hola %@ mundo,Bonjour %@ monde
81
+ dots_test,Wait...,Espera...,Attendez...
82
+ ampersand_test,Tom & Jerry,Tom & Jerry,Tom & Jerry
83
+ [init-node],module,module,module
84
+ nested_key,Module Key,Clave Módulo,Clé Module
85
+ [end-node],end,end,end
86
+ [end],,,
87
+ ```
88
+
89
+ This gives 8 terms between `[key]` and `[end]`. Languages: en (default, marked with `*`), es, fr.
90
+ All cells have values to avoid nil-translation bugs in writers.
91
+
92
+ **Step 2: Create `spec/support/shared_terms.rb`**
93
+
94
+ ```ruby
95
+ require 'localio/term'
96
+
97
+ RSpec.shared_context 'standard terms' do
98
+ let(:languages) { { 'en' => 1, 'es' => 2, 'fr' => 3 } }
99
+ let(:default_language) { 'en' }
100
+ let(:terms) do
101
+ [
102
+ Term.new('[comment]').tap do |t|
103
+ t.values['en'] = 'Section General'
104
+ t.values['es'] = 'Section General'
105
+ t.values['fr'] = 'Section General'
106
+ end,
107
+ Term.new('app_name').tap do |t|
108
+ t.values['en'] = 'My App'
109
+ t.values['es'] = 'Mi Aplicación'
110
+ t.values['fr'] = 'Mon Application'
111
+ end,
112
+ Term.new('greeting').tap do |t|
113
+ t.values['en'] = 'Hello %@ world'
114
+ t.values['es'] = 'Hola %@ mundo'
115
+ t.values['fr'] = 'Bonjour %@ monde'
116
+ end,
117
+ Term.new('dots_test').tap do |t|
118
+ t.values['en'] = 'Wait...'
119
+ t.values['es'] = 'Espera...'
120
+ t.values['fr'] = 'Attendez...'
121
+ end,
122
+ Term.new('ampersand_test').tap do |t|
123
+ t.values['en'] = 'Tom & Jerry'
124
+ t.values['es'] = 'Tom & Jerry'
125
+ t.values['fr'] = 'Tom & Jerry'
126
+ end,
127
+ ]
128
+ end
129
+ end
130
+ ```
131
+
132
+ **Step 3: Commit**
133
+
134
+ ```bash
135
+ git add spec/fixtures/sample.csv spec/support/shared_terms.rb
136
+ git commit -m "test: add sample CSV fixture and shared terms context"
137
+ ```
138
+
139
+ ---
140
+
141
+ ### Task 3: StringHelper tests
142
+
143
+ **Files:**
144
+ - Create: `spec/localio/string_helper_spec.rb`
145
+
146
+ **Step 1: Write test**
147
+
148
+ ```ruby
149
+ require 'localio/string_helper'
150
+
151
+ RSpec.describe String do
152
+ describe '#space_to_underscore' do
153
+ it { expect('hello world'.space_to_underscore).to eq('hello_world') }
154
+ it { expect('hello'.space_to_underscore).to eq('hello') }
155
+ end
156
+
157
+ describe '#strip_tag' do
158
+ it 'strips single-letter bracket tags from the start' do
159
+ expect('[a]hello'.strip_tag).to eq('hello')
160
+ end
161
+ it 'does not strip multi-letter bracket tags' do
162
+ expect('[comment]hello'.strip_tag).to eq('[comment]hello')
163
+ end
164
+ it 'does not strip tags not at the start' do
165
+ expect('hello[a]'.strip_tag).to eq('hello[a]')
166
+ end
167
+ end
168
+
169
+ describe '#camel_case' do
170
+ it { expect('hello_world'.camel_case).to eq('HelloWorld') }
171
+ it { expect('HelloWorld'.camel_case).to eq('HelloWorld') }
172
+ end
173
+
174
+ describe '#replace_escaped' do
175
+ it { expect('a`+b'.replace_escaped).to eq('a+b') }
176
+ it { expect('a`=b'.replace_escaped).to eq('a=b') }
177
+ it { expect("a\\+b".replace_escaped).to eq('a+b') }
178
+ end
179
+
180
+ describe '#underscore' do
181
+ it { expect('HelloWorld'.underscore).to eq('hello_world') }
182
+ end
183
+
184
+ describe '#uncapitalize' do
185
+ it { expect('Hello'.uncapitalize).to eq('hello') }
186
+ end
187
+
188
+ describe '#blank?' do
189
+ it { expect(''.blank?).to be true }
190
+ it { expect('hello'.blank?).to be false }
191
+ end
192
+
193
+ describe '#green / #yellow / #red / #cyan' do
194
+ it { expect('ok'.green).to include("\e[32m") }
195
+ it { expect('ok'.yellow).to include("\e[33m") }
196
+ end
197
+ end
198
+ ```
199
+
200
+ **Step 2: Run test**
201
+
202
+ Run: `bundle exec rspec spec/localio/string_helper_spec.rb`
203
+ Expected: all pass
204
+
205
+ **Step 3: Commit**
206
+
207
+ ```bash
208
+ git add spec/localio/string_helper_spec.rb
209
+ git commit -m "test: StringHelper string extension tests"
210
+ ```
211
+
212
+ ---
213
+
214
+ ### Task 4: Term and Segment model tests
215
+
216
+ **Files:**
217
+ - Create: `spec/localio/term_spec.rb`
218
+ - Create: `spec/localio/segment_spec.rb`
219
+
220
+ **Step 1: Write `spec/localio/term_spec.rb`**
221
+
222
+ ```ruby
223
+ require 'localio/term'
224
+
225
+ RSpec.describe Term do
226
+ subject(:term) { Term.new('app_name') }
227
+
228
+ it 'stores the keyword' do
229
+ expect(term.keyword).to eq('app_name')
230
+ end
231
+
232
+ it 'initializes with empty values hash' do
233
+ expect(term.values).to be_empty
234
+ end
235
+
236
+ it 'stores values by language' do
237
+ term.values['en'] = 'My App'
238
+ expect(term.values['en']).to eq('My App')
239
+ end
240
+
241
+ describe '#is_comment?' do
242
+ it { expect(Term.new('[comment]').is_comment?).to be true }
243
+ it { expect(Term.new('[COMMENT]').is_comment?).to be true }
244
+ it { expect(term.is_comment?).to be false }
245
+ end
246
+ end
247
+ ```
248
+
249
+ **Step 2: Write `spec/localio/segment_spec.rb`**
250
+
251
+ ```ruby
252
+ require 'localio/string_helper'
253
+ require 'localio/segment'
254
+
255
+ RSpec.describe Segment do
256
+ subject(:segment) { Segment.new('app_name', 'My App', 'en') }
257
+
258
+ it 'stores key, translation, and language' do
259
+ expect(segment.key).to eq('app_name')
260
+ expect(segment.translation).to eq('My App')
261
+ expect(segment.language).to eq('en')
262
+ end
263
+
264
+ it 'processes translation through replace_escaped' do
265
+ seg = Segment.new('key', 'hello`+world', 'en')
266
+ expect(seg.translation).to eq('hello+world')
267
+ end
268
+
269
+ describe '#is_comment?' do
270
+ it 'returns true when key is nil' do
271
+ segment.key = nil
272
+ expect(segment.is_comment?).to be true
273
+ end
274
+ it 'returns false when key is set' do
275
+ expect(segment.is_comment?).to be false
276
+ end
277
+ end
278
+ end
279
+ ```
280
+
281
+ **Step 3: Run tests**
282
+
283
+ Run: `bundle exec rspec spec/localio/term_spec.rb spec/localio/segment_spec.rb`
284
+ Expected: all pass
285
+
286
+ **Step 4: Commit**
287
+
288
+ ```bash
289
+ git add spec/localio/term_spec.rb spec/localio/segment_spec.rb
290
+ git commit -m "test: Term and Segment model tests"
291
+ ```
292
+
293
+ ---
294
+
295
+ ### Task 5: SegmentsListHolder, Filter, and Formatter tests
296
+
297
+ **Files:**
298
+ - Create: `spec/localio/segments_list_holder_spec.rb`
299
+ - Create: `spec/localio/filter_spec.rb`
300
+ - Create: `spec/localio/formatter_spec.rb`
301
+
302
+ **Step 1: Write `spec/localio/segments_list_holder_spec.rb`**
303
+
304
+ ```ruby
305
+ require 'localio/segments_list_holder'
306
+
307
+ RSpec.describe SegmentsListHolder do
308
+ subject(:holder) { SegmentsListHolder.new('en') }
309
+
310
+ it { expect(holder.language).to eq('en') }
311
+ it { expect(holder.segments).to be_empty }
312
+
313
+ describe '#get_binding' do
314
+ it 'returns a Binding' do
315
+ expect(holder.get_binding).to be_a(Binding)
316
+ end
317
+
318
+ it 'exposes @language in the binding' do
319
+ expect(eval('@language', holder.get_binding)).to eq('en')
320
+ end
321
+
322
+ it 'exposes @segments in the binding' do
323
+ expect(eval('@segments', holder.get_binding)).to eq([])
324
+ end
325
+ end
326
+ end
327
+ ```
328
+
329
+ **Step 2: Write `spec/localio/filter_spec.rb`**
330
+
331
+ Note: `Filter` operates on `Term` objects (not `Segment`), matching against `term.keyword`.
332
+
333
+ ```ruby
334
+ require 'localio/term'
335
+ require 'localio/filter'
336
+
337
+ RSpec.describe Filter do
338
+ let(:terms) do
339
+ ['app_name', 'app_title', 'settings_title', 'settings_back', '[comment]'].map { |kw| Term.new(kw) }
340
+ end
341
+
342
+ describe '.apply_filter' do
343
+ it 'returns all terms when no filters set' do
344
+ expect(Filter.apply_filter(terms, nil, nil)).to eq(terms)
345
+ end
346
+
347
+ context 'with only filter' do
348
+ it 'keeps terms matching the regex' do
349
+ result = Filter.apply_filter(terms, { keys: 'app_' }, nil)
350
+ expect(result.map(&:keyword)).to contain_exactly('app_name', 'app_title')
351
+ end
352
+
353
+ it 'returns empty array when nothing matches' do
354
+ expect(Filter.apply_filter(terms, { keys: 'nonexistent' }, nil)).to be_empty
355
+ end
356
+ end
357
+
358
+ context 'with except filter' do
359
+ it 'excludes terms matching the regex' do
360
+ result = Filter.apply_filter(terms, nil, { keys: 'settings_' })
361
+ expect(result.map(&:keyword)).not_to include('settings_title', 'settings_back')
362
+ expect(result.map(&:keyword)).to include('app_name', 'app_title')
363
+ end
364
+ end
365
+
366
+ context 'with both filters' do
367
+ it 'applies only first then except' do
368
+ result = Filter.apply_filter(terms, { keys: 'app_' }, { keys: 'title' })
369
+ expect(result.map(&:keyword)).to contain_exactly('app_name')
370
+ end
371
+ end
372
+ end
373
+ end
374
+ ```
375
+
376
+ **Step 3: Write `spec/localio/formatter_spec.rb`**
377
+
378
+ ```ruby
379
+ require 'localio/string_helper'
380
+ require 'localio/formatter'
381
+
382
+ RSpec.describe Formatter do
383
+ let(:smart_callback) { ->(key) { key.upcase } }
384
+
385
+ describe '.format' do
386
+ it ':smart delegates to callback' do
387
+ expect(Formatter.format('hello', :smart, smart_callback)).to eq('HELLO')
388
+ end
389
+
390
+ it ':none returns key unchanged' do
391
+ expect(Formatter.format('Hello World', :none, smart_callback)).to eq('Hello World')
392
+ end
393
+
394
+ it ':camel_case converts to CamelCase' do
395
+ expect(Formatter.format('hello world', :camel_case, smart_callback)).to eq('HelloWorld')
396
+ end
397
+
398
+ it ':camel_case strips single-letter bracket tags' do
399
+ expect(Formatter.format('[a]hello', :camel_case, smart_callback)).to eq('Hello')
400
+ end
401
+
402
+ it ':snake_case converts spaces to underscores and downcases' do
403
+ expect(Formatter.format('Hello World', :snake_case, smart_callback)).to eq('hello_world')
404
+ end
405
+
406
+ it 'raises ArgumentError for unknown formatter' do
407
+ expect { Formatter.format('key', :unknown, smart_callback) }.to raise_error(ArgumentError)
408
+ end
409
+ end
410
+ end
411
+ ```
412
+
413
+ **Step 4: Run tests**
414
+
415
+ Run: `bundle exec rspec spec/localio/segments_list_holder_spec.rb spec/localio/filter_spec.rb spec/localio/formatter_spec.rb`
416
+ Expected: all pass
417
+
418
+ **Step 5: Commit**
419
+
420
+ ```bash
421
+ git add spec/localio/segments_list_holder_spec.rb spec/localio/filter_spec.rb spec/localio/formatter_spec.rb
422
+ git commit -m "test: SegmentsListHolder, Filter, and Formatter tests"
423
+ ```
424
+
425
+ ---
426
+
427
+ ### Task 6: TemplateHandler tests
428
+
429
+ **Files:**
430
+ - Create: `spec/localio/template_handler_spec.rb`
431
+
432
+ The `TemplateHandler` writes an intermediate file in cwd before copying to the target directory.
433
+ Use `Dir.chdir(tmpdir) { }` (block form, restores cwd on exit) to keep intermediate files in tmpdir.
434
+
435
+ **Step 1: Write test**
436
+
437
+ ```ruby
438
+ require 'localio/string_helper'
439
+ require 'localio/segment'
440
+ require 'localio/segments_list_holder'
441
+ require 'localio/template_handler'
442
+
443
+ RSpec.describe TemplateHandler do
444
+ let(:holder) do
445
+ h = SegmentsListHolder.new('en')
446
+ h.segments << Segment.new('app_name', 'My App', 'en')
447
+ h.segments << Segment.new('greeting', 'Hello world', 'en')
448
+ h
449
+ end
450
+
451
+ describe '.process_template' do
452
+ it 'creates the output file in the target directory' do
453
+ Dir.mktmpdir do |tmpdir|
454
+ Dir.chdir(tmpdir) do
455
+ TemplateHandler.process_template('rails_localizable.erb', tmpdir, 'en.yml', holder)
456
+ expect(File).to exist(File.join(tmpdir, 'en.yml'))
457
+ end
458
+ end
459
+ end
460
+
461
+ it 'renders ERB template content correctly' do
462
+ Dir.mktmpdir do |tmpdir|
463
+ Dir.chdir(tmpdir) do
464
+ TemplateHandler.process_template('rails_localizable.erb', tmpdir, 'en.yml', holder)
465
+ content = File.read(File.join(tmpdir, 'en.yml'))
466
+ expect(content).to include('en:')
467
+ expect(content).to include('app_name: "My App"')
468
+ expect(content).to include('greeting: "Hello world"')
469
+ end
470
+ end
471
+ end
472
+
473
+ it 'does not leave a stray temp file in the working directory' do
474
+ Dir.mktmpdir do |tmpdir|
475
+ Dir.chdir(tmpdir) do
476
+ TemplateHandler.process_template('rails_localizable.erb', tmpdir, 'en.yml', holder)
477
+ files = Dir.entries(tmpdir).reject { |f| f.start_with?('.') }
478
+ expect(files).to eq(['en.yml'])
479
+ end
480
+ end
481
+ end
482
+
483
+ it 'creates subdirectories as needed' do
484
+ Dir.mktmpdir do |tmpdir|
485
+ Dir.chdir(tmpdir) do
486
+ subdir = File.join(tmpdir, 'values')
487
+ TemplateHandler.process_template('rails_localizable.erb', subdir, 'en.yml', holder)
488
+ expect(File).to exist(File.join(subdir, 'en.yml'))
489
+ end
490
+ end
491
+ end
492
+ end
493
+ end
494
+ ```
495
+
496
+ **Step 2: Run test**
497
+
498
+ Run: `bundle exec rspec spec/localio/template_handler_spec.rb`
499
+ Expected: all pass
500
+
501
+ **Step 3: Commit**
502
+
503
+ ```bash
504
+ git add spec/localio/template_handler_spec.rb
505
+ git commit -m "test: TemplateHandler tests"
506
+ ```
507
+
508
+ ---
509
+
510
+ ### Task 7: CsvProcessor tests
511
+
512
+ **Files:**
513
+ - Create: `spec/localio/processors/csv_processor_spec.rb`
514
+
515
+ **Step 1: Write test**
516
+
517
+ ```ruby
518
+ require 'localio/term'
519
+ require 'localio/string_helper'
520
+ require 'localio/processors/csv_processor'
521
+
522
+ RSpec.describe CsvProcessor do
523
+ let(:fixture_path) { File.expand_path('../../../fixtures/sample.csv', __FILE__) }
524
+ let(:platform_options) { {} }
525
+ let(:options) { { path: fixture_path } }
526
+
527
+ describe '.load_localizables' do
528
+ subject(:result) { CsvProcessor.load_localizables(platform_options, options) }
529
+
530
+ it 'returns languages en, es, fr' do
531
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
532
+ end
533
+
534
+ it 'sets en as default language (marked with *)' do
535
+ expect(result[:default_language]).to eq('en')
536
+ end
537
+
538
+ it 'returns 8 terms between [key] and [end]' do
539
+ expect(result[:segments].count).to eq(8)
540
+ end
541
+
542
+ it 'parses term keywords correctly' do
543
+ keywords = result[:segments].map(&:keyword)
544
+ expect(keywords).to include('app_name', 'greeting', '[comment]', '[init-node]', '[end-node]')
545
+ end
546
+
547
+ it 'parses translations for each language' do
548
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
549
+ expect(app_name.values['en']).to eq('My App')
550
+ expect(app_name.values['es']).to eq('Mi Aplicación')
551
+ expect(app_name.values['fr']).to eq('Mon Application')
552
+ end
553
+
554
+ it 'identifies comment rows' do
555
+ comment = result[:segments].find { |t| t.keyword == '[comment]' }
556
+ expect(comment.is_comment?).to be true
557
+ end
558
+
559
+ it 'raises ArgumentError when :path is missing' do
560
+ expect { CsvProcessor.load_localizables({}, {}) }
561
+ .to raise_error(ArgumentError, /:path attribute is missing/)
562
+ end
563
+
564
+ it 'raises IndexError when [key] marker is missing' do
565
+ Dir.mktmpdir do |tmpdir|
566
+ path = File.join(tmpdir, 'bad.csv')
567
+ File.write(path, "no,key,row\ndata,here,\n[end],,,\n")
568
+ expect { CsvProcessor.load_localizables({}, { path: path }) }
569
+ .to raise_error(IndexError, /Could not find any \[key\]/)
570
+ end
571
+ end
572
+
573
+ it 'raises IndexError when [end] marker is missing' do
574
+ Dir.mktmpdir do |tmpdir|
575
+ path = File.join(tmpdir, 'bad.csv')
576
+ File.write(path, "title,,,\n[key],*en,es,\ndata,val,val,\n")
577
+ expect { CsvProcessor.load_localizables({}, { path: path }) }
578
+ .to raise_error(IndexError, /Could not find any \[end\]/)
579
+ end
580
+ end
581
+
582
+ context 'with override_default option' do
583
+ let(:platform_options) { { override_default: 'es' } }
584
+
585
+ it 'uses the overridden default language' do
586
+ expect(result[:default_language]).to eq('es')
587
+ end
588
+ end
589
+
590
+ context 'with avoid_lang_downcase option' do
591
+ let(:tmpdir) { Dir.mktmpdir }
592
+ let(:options) do
593
+ path = File.join(tmpdir, 'upper.csv')
594
+ File.write(path, "Title,,,\n[key],*EN,ES,FR\napp_name,My App,Mi App,Mon App\n[end],,,\n")
595
+ { path: path }
596
+ end
597
+ let(:platform_options) { { avoid_lang_downcase: true } }
598
+
599
+ after { FileUtils.rm_rf(tmpdir) }
600
+
601
+ it 'preserves language case' do
602
+ expect(result[:languages].keys).to contain_exactly('EN', 'ES', 'FR')
603
+ end
604
+ end
605
+ end
606
+ end
607
+ ```
608
+
609
+ **Step 2: Run test**
610
+
611
+ Run: `bundle exec rspec spec/localio/processors/csv_processor_spec.rb`
612
+ Expected: all pass (CSV is stdlib, no external gem)
613
+
614
+ **Step 3: Commit**
615
+
616
+ ```bash
617
+ git add spec/localio/processors/csv_processor_spec.rb
618
+ git commit -m "test: CsvProcessor tests"
619
+ ```
620
+
621
+ ---
622
+
623
+ ### Task 8: XlsxProcessor tests (mocked)
624
+
625
+ **Files:**
626
+ - Create: `spec/localio/processors/xlsx_processor_spec.rb`
627
+
628
+ Mock `SimpleXlsxReader` to avoid needing a binary fixture file.
629
+
630
+ **Step 1: Write test**
631
+
632
+ ```ruby
633
+ require 'localio/term'
634
+ require 'localio/string_helper'
635
+ require 'localio/processors/xlsx_processor'
636
+
637
+ RSpec.describe XlsxProcessor do
638
+ let(:rows) do
639
+ [
640
+ ['Title', nil, nil, nil],
641
+ ['[key]', '*en', 'es', 'fr'],
642
+ ['[comment]', 'Section General', 'Section General', 'Section General'],
643
+ ['app_name', 'My App', 'Mi Aplicación', 'Mon Application'],
644
+ ['greeting', 'Hello', 'Hola', 'Bonjour'],
645
+ ['[end]', nil, nil, nil],
646
+ ]
647
+ end
648
+
649
+ let(:sheet_double) { double('sheet', rows: rows) }
650
+ let(:book_double) { double('book', sheets: [sheet_double]) }
651
+
652
+ before { allow(SimpleXlsxReader).to receive(:open).and_return(book_double) }
653
+
654
+ let(:options) { { path: 'fake.xlsx', sheet: 0 } }
655
+ let(:platform_options) { {} }
656
+
657
+ describe '.load_localizables' do
658
+ subject(:result) { XlsxProcessor.load_localizables(platform_options, options) }
659
+
660
+ it 'raises ArgumentError when :path is missing' do
661
+ expect { XlsxProcessor.load_localizables({}, {}) }
662
+ .to raise_error(ArgumentError, /:path attribute is missing/)
663
+ end
664
+
665
+ it 'returns languages en, es, fr' do
666
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
667
+ end
668
+
669
+ it 'sets en as default language' do
670
+ expect(result[:default_language]).to eq('en')
671
+ end
672
+
673
+ it 'returns 3 terms' do
674
+ expect(result[:segments].count).to eq(3)
675
+ end
676
+
677
+ it 'parses translations correctly' do
678
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
679
+ expect(app_name.values['en']).to eq('My App')
680
+ expect(app_name.values['es']).to eq('Mi Aplicación')
681
+ end
682
+
683
+ it 'selects sheet by string name' do
684
+ named_sheet = double('sheet', name: 'Translations', rows: rows)
685
+ allow(book_double).to receive(:sheets).and_return([named_sheet])
686
+ allow(book_double.sheets).to receive(:detect).and_call_original
687
+ result = XlsxProcessor.load_localizables({}, { path: 'fake.xlsx', sheet: 'Translations' })
688
+ expect(result[:languages].keys).to include('en')
689
+ end
690
+
691
+ it 'raises when sheet is nil' do
692
+ allow(book_double).to receive(:sheets).and_return([double('sheet', rows: rows)])
693
+ allow(book_double.sheets).to receive(:detect).and_return(nil)
694
+ expect { XlsxProcessor.load_localizables({}, { path: 'fake.xlsx', sheet: 'Missing' }) }
695
+ .to raise_error(RuntimeError, /Unable to retrieve/)
696
+ end
697
+ end
698
+ end
699
+ ```
700
+
701
+ **Step 2: Run test**
702
+
703
+ Run: `bundle exec rspec spec/localio/processors/xlsx_processor_spec.rb`
704
+ Expected: pass if `simple_xlsx_reader` installs under current Ruby; note failures for Phase 2 if not
705
+
706
+ **Step 3: Commit**
707
+
708
+ ```bash
709
+ git add spec/localio/processors/xlsx_processor_spec.rb
710
+ git commit -m "test: XlsxProcessor tests (mocked)"
711
+ ```
712
+
713
+ ---
714
+
715
+ ### Task 9: XlsProcessor tests (mocked)
716
+
717
+ **Files:**
718
+ - Create: `spec/localio/processors/xls_processor_spec.rb`
719
+
720
+ Mock the `Spreadsheet` gem worksheet interface. XLS uses `worksheet[row, col]` (0-indexed rows).
721
+
722
+ **Step 1: Write test**
723
+
724
+ ```ruby
725
+ require 'localio/term'
726
+ require 'localio/string_helper'
727
+ require 'localio/processors/xls_processor'
728
+
729
+ RSpec.describe XlsProcessor do
730
+ let(:data) do
731
+ {
732
+ [0, 0] => '[key]', [0, 1] => '*en', [0, 2] => 'es', [0, 3] => 'fr',
733
+ [1, 0] => 'app_name', [1, 1] => 'My App', [1, 2] => 'Mi Aplicación', [1, 3] => 'Mon Application',
734
+ [2, 0] => 'greeting', [2, 1] => 'Hello', [2, 2] => 'Hola', [2, 3] => 'Bonjour',
735
+ [3, 0] => '[end]', [3, 1] => '', [3, 2] => '', [3, 3] => '',
736
+ }
737
+ end
738
+
739
+ let(:worksheet) do
740
+ ws = double('worksheet')
741
+ allow(ws).to receive(:[]) { |row, col| data[[row, col]].to_s }
742
+ allow(ws).to receive(:row_count).and_return(3)
743
+ allow(ws).to receive(:column_count).and_return(3)
744
+ ws
745
+ end
746
+
747
+ let(:book_double) { double('book') }
748
+
749
+ before do
750
+ allow(Spreadsheet).to receive(:client_encoding=)
751
+ allow(Spreadsheet).to receive(:open).and_return(book_double)
752
+ allow(book_double).to receive(:worksheet).with(0).and_return(worksheet)
753
+ end
754
+
755
+ let(:options) { { path: 'fake.xls' } }
756
+ let(:platform_options) { {} }
757
+
758
+ describe '.load_localizables' do
759
+ subject(:result) { XlsProcessor.load_localizables(platform_options, options) }
760
+
761
+ it 'raises ArgumentError when :path is missing' do
762
+ expect { XlsProcessor.load_localizables({}, {}) }
763
+ .to raise_error(ArgumentError, /:path attribute is missing/)
764
+ end
765
+
766
+ it 'returns languages en, es, fr' do
767
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
768
+ end
769
+
770
+ it 'sets en as default language' do
771
+ expect(result[:default_language]).to eq('en')
772
+ end
773
+
774
+ it 'returns 2 terms' do
775
+ expect(result[:segments].count).to eq(2)
776
+ end
777
+
778
+ it 'parses translations correctly' do
779
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
780
+ expect(app_name.values['en']).to eq('My App')
781
+ end
782
+
783
+ it 'raises when worksheet is nil' do
784
+ allow(book_double).to receive(:worksheet).and_return(nil)
785
+ expect { XlsProcessor.load_localizables({}, { path: 'fake.xls' }) }
786
+ .to raise_error(RuntimeError, /Unable to retrieve/)
787
+ end
788
+ end
789
+ end
790
+ ```
791
+
792
+ **Step 2: Run test**
793
+
794
+ Run: `bundle exec rspec spec/localio/processors/xls_processor_spec.rb`
795
+ Expected: pass if `spreadsheet` gem installs; note failures for Phase 2 if not
796
+
797
+ **Step 3: Commit**
798
+
799
+ ```bash
800
+ git add spec/localio/processors/xls_processor_spec.rb
801
+ git commit -m "test: XlsProcessor tests (mocked)"
802
+ ```
803
+
804
+ ---
805
+
806
+ ### Task 10: GoogleDriveProcessor tests (mocked)
807
+
808
+ **Files:**
809
+ - Create: `spec/localio/processors/google_drive_processor_spec.rb`
810
+
811
+ Mock the entire GoogleDrive session and auth. Focus on argument validation and parsing logic.
812
+
813
+ **Step 1: Write test**
814
+
815
+ ```ruby
816
+ require 'localio/term'
817
+ require 'localio/string_helper'
818
+ require 'localio/processors/google_drive_processor'
819
+
820
+ RSpec.describe GoogleDriveProcessor do
821
+ let(:ws_data) do
822
+ {
823
+ [1, 1] => '[key]', [1, 2] => '*en', [1, 3] => 'es',
824
+ [2, 1] => 'app_name', [2, 2] => 'My App', [2, 3] => 'Mi Aplicación',
825
+ [3, 1] => 'greeting', [3, 2] => 'Hello', [3, 3] => 'Hola',
826
+ [4, 1] => '[end]', [4, 2] => '', [4, 3] => '',
827
+ }
828
+ end
829
+
830
+ let(:worksheet) do
831
+ ws = double('worksheet')
832
+ allow(ws).to receive(:[]) { |row, col| ws_data[[row, col]].to_s }
833
+ allow(ws).to receive(:max_rows).and_return(4)
834
+ allow(ws).to receive(:max_cols).and_return(3)
835
+ ws
836
+ end
837
+
838
+ let(:spreadsheet_double) { double('spreadsheet', title: 'My Translations') }
839
+ let(:session_double) { double('session') }
840
+
841
+ before do
842
+ allow(spreadsheet_double).to receive(:worksheets).and_return([worksheet])
843
+ allow(session_double).to receive(:spreadsheets).and_return([spreadsheet_double])
844
+ allow(GoogleDrive).to receive(:login_with_oauth).and_return(session_double)
845
+
846
+ auth = double('auth',
847
+ authorization_uri: 'http://example.com',
848
+ access_token: 'token',
849
+ refresh_token: 'refresh'
850
+ )
851
+ allow(auth).to receive(:client_id=)
852
+ allow(auth).to receive(:client_secret=)
853
+ allow(auth).to receive(:scope=)
854
+ allow(auth).to receive(:redirect_uri=)
855
+ allow(auth).to receive(:refresh_token=)
856
+ allow(auth).to receive(:refresh!)
857
+
858
+ client = double('client', authorization: auth)
859
+ stub_const('Google::APIClient', double(new: client))
860
+ stub_const('Localio::VERSION', '0.1.7')
861
+
862
+ config = double('config', has?: false, get: nil)
863
+ allow(config).to receive(:store)
864
+ allow(config).to receive(:persist)
865
+ allow(ConfigStore).to receive(:new).and_return(config)
866
+ end
867
+
868
+ let(:options) do
869
+ { spreadsheet: 'My Translations', client_id: 'id', client_secret: 'secret', client_token: 'token', sheet: 0 }
870
+ end
871
+
872
+ describe '.load_localizables' do
873
+ subject(:result) { GoogleDriveProcessor.load_localizables({}, options) }
874
+
875
+ it 'raises when :spreadsheet is missing' do
876
+ expect { GoogleDriveProcessor.load_localizables({}, { client_id: 'a', client_secret: 'b' }) }
877
+ .to raise_error(ArgumentError, /:spreadsheet required/)
878
+ end
879
+
880
+ it 'raises when :login is provided (deprecated)' do
881
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', login: 'u', client_id: 'a', client_secret: 'b' }) }
882
+ .to raise_error(ArgumentError, /:login is deprecated/)
883
+ end
884
+
885
+ it 'raises when :client_id is missing' do
886
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', client_secret: 'b' }) }
887
+ .to raise_error(ArgumentError, /:client_id required/)
888
+ end
889
+
890
+ it 'raises when :client_secret is missing' do
891
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', client_id: 'a' }) }
892
+ .to raise_error(ArgumentError, /:client_secret required/)
893
+ end
894
+
895
+ it 'returns languages en and es' do
896
+ expect(result[:languages].keys).to contain_exactly('en', 'es')
897
+ end
898
+
899
+ it 'sets en as default language' do
900
+ expect(result[:default_language]).to eq('en')
901
+ end
902
+
903
+ it 'parses translations' do
904
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
905
+ expect(app_name.values['en']).to eq('My App')
906
+ end
907
+ end
908
+ end
909
+ ```
910
+
911
+ **Step 2: Run test**
912
+
913
+ Run: `bundle exec rspec spec/localio/processors/google_drive_processor_spec.rb`
914
+ Expected: may fail if `google_drive ~> 1.0` does not install under Ruby 3.x — note for Phase 2
915
+
916
+ **Step 3: Commit**
917
+
918
+ ```bash
919
+ git add spec/localio/processors/google_drive_processor_spec.rb
920
+ git commit -m "test: GoogleDriveProcessor tests (mocked)"
921
+ ```
922
+
923
+ ---
924
+
925
+ ### Task 11: AndroidWriter tests
926
+
927
+ **Files:**
928
+ - Create: `spec/localio/writers/android_writer_spec.rb`
929
+
930
+ **Step 1: Write test**
931
+
932
+ ```ruby
933
+ require 'localio/string_helper'
934
+ require 'localio/term'
935
+ require 'localio/segment'
936
+ require 'localio/segments_list_holder'
937
+ require 'localio/formatter'
938
+ require 'localio/template_handler'
939
+ require 'localio/writers/android_writer'
940
+
941
+ RSpec.describe AndroidWriter do
942
+ include_context 'standard terms'
943
+
944
+ let(:options) { { default_language: 'en' } }
945
+
946
+ describe '.write' do
947
+ it 'creates values/strings.xml for the default language' do
948
+ Dir.mktmpdir do |tmpdir|
949
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
950
+ expect(File).to exist(File.join(tmpdir, 'values', 'strings.xml'))
951
+ end
952
+ end
953
+
954
+ it 'creates values-{lang}/strings.xml for non-default languages' do
955
+ Dir.mktmpdir do |tmpdir|
956
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
957
+ expect(File).to exist(File.join(tmpdir, 'values-es', 'strings.xml'))
958
+ expect(File).to exist(File.join(tmpdir, 'values-fr', 'strings.xml'))
959
+ end
960
+ end
961
+
962
+ it 'converts & to &amp; in translations' do
963
+ Dir.mktmpdir do |tmpdir|
964
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
965
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
966
+ expect(content).to include('&amp; Jerry')
967
+ expect(content).not_to include('"Tom & Jerry"')
968
+ end
969
+ end
970
+
971
+ it 'converts ... to … in translations' do
972
+ Dir.mktmpdir do |tmpdir|
973
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
974
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
975
+ expect(content).to include('Wait…')
976
+ end
977
+ end
978
+
979
+ it 'converts %@ to %s in translations' do
980
+ Dir.mktmpdir do |tmpdir|
981
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
982
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
983
+ expect(content).to include('%s world')
984
+ end
985
+ end
986
+
987
+ it 'renders comment rows as XML comments' do
988
+ Dir.mktmpdir do |tmpdir|
989
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
990
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
991
+ expect(content).to include('<!-- Section General -->')
992
+ end
993
+ end
994
+
995
+ it 'includes app_name string resource' do
996
+ Dir.mktmpdir do |tmpdir|
997
+ Dir.chdir(tmpdir) { AndroidWriter.write(languages, terms, tmpdir, :smart, options) }
998
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
999
+ expect(content).to include('<string name="app_name">My App</string>')
1000
+ end
1001
+ end
1002
+ end
1003
+ end
1004
+ ```
1005
+
1006
+ **Step 2: Run test**
1007
+
1008
+ Run: `bundle exec rspec spec/localio/writers/android_writer_spec.rb`
1009
+ Expected: all pass
1010
+
1011
+ **Step 3: Commit**
1012
+
1013
+ ```bash
1014
+ git add spec/localio/writers/android_writer_spec.rb
1015
+ git commit -m "test: AndroidWriter tests"
1016
+ ```
1017
+
1018
+ ---
1019
+
1020
+ ### Task 12: IosWriter and SwiftWriter tests
1021
+
1022
+ **Files:**
1023
+ - Create: `spec/localio/writers/ios_writer_spec.rb`
1024
+ - Create: `spec/localio/writers/swift_writer_spec.rb`
1025
+
1026
+ **Step 1: Write `spec/localio/writers/ios_writer_spec.rb`**
1027
+
1028
+ ```ruby
1029
+ require 'localio/string_helper'
1030
+ require 'localio/term'
1031
+ require 'localio/segment'
1032
+ require 'localio/segments_list_holder'
1033
+ require 'localio/formatter'
1034
+ require 'localio/template_handler'
1035
+ require 'localio/writers/ios_writer'
1036
+
1037
+ RSpec.describe IosWriter do
1038
+ include_context 'standard terms'
1039
+
1040
+ let(:options) { { default_language: 'en' } }
1041
+
1042
+ describe '.write' do
1043
+ it 'creates Localizable.strings in {lang}.lproj/ for each language' do
1044
+ Dir.mktmpdir do |tmpdir|
1045
+ Dir.chdir(tmpdir) { IosWriter.write(languages, terms, tmpdir, :smart, options) }
1046
+ expect(File).to exist(File.join(tmpdir, 'en.lproj', 'Localizable.strings'))
1047
+ expect(File).to exist(File.join(tmpdir, 'es.lproj', 'Localizable.strings'))
1048
+ end
1049
+ end
1050
+
1051
+ it 'creates LocalizableConstants.h by default' do
1052
+ Dir.mktmpdir do |tmpdir|
1053
+ Dir.chdir(tmpdir) { IosWriter.write(languages, terms, tmpdir, :smart, options) }
1054
+ expect(File).to exist(File.join(tmpdir, 'LocalizableConstants.h'))
1055
+ end
1056
+ end
1057
+
1058
+ it 'skips LocalizableConstants.h when create_constants is false' do
1059
+ Dir.mktmpdir do |tmpdir|
1060
+ Dir.chdir(tmpdir) { IosWriter.write(languages, terms, tmpdir, :smart, options.merge(create_constants: false)) }
1061
+ expect(File).not_to exist(File.join(tmpdir, 'LocalizableConstants.h'))
1062
+ end
1063
+ end
1064
+
1065
+ it 'renders comment rows as line comments' do
1066
+ Dir.mktmpdir do |tmpdir|
1067
+ Dir.chdir(tmpdir) { IosWriter.write(languages, terms, tmpdir, :smart, options) }
1068
+ content = File.read(File.join(tmpdir, 'en.lproj', 'Localizable.strings'))
1069
+ expect(content).to include('// Section General')
1070
+ end
1071
+ end
1072
+
1073
+ it 'includes translation values' do
1074
+ Dir.mktmpdir do |tmpdir|
1075
+ Dir.chdir(tmpdir) { IosWriter.write(languages, terms, tmpdir, :smart, options) }
1076
+ content = File.read(File.join(tmpdir, 'en.lproj', 'Localizable.strings'))
1077
+ expect(content).to include('My App')
1078
+ end
1079
+ end
1080
+ end
1081
+ end
1082
+ ```
1083
+
1084
+ **Step 2: Write `spec/localio/writers/swift_writer_spec.rb`**
1085
+
1086
+ ```ruby
1087
+ require 'localio/string_helper'
1088
+ require 'localio/term'
1089
+ require 'localio/segment'
1090
+ require 'localio/segments_list_holder'
1091
+ require 'localio/formatter'
1092
+ require 'localio/template_handler'
1093
+ require 'localio/writers/swift_writer'
1094
+
1095
+ RSpec.describe SwiftWriter do
1096
+ include_context 'standard terms'
1097
+
1098
+ let(:options) { { default_language: 'en' } }
1099
+
1100
+ describe '.write' do
1101
+ it 'creates Localizable.strings in {lang}.lproj/' do
1102
+ Dir.mktmpdir do |tmpdir|
1103
+ Dir.chdir(tmpdir) { SwiftWriter.write(languages, terms, tmpdir, :smart, options) }
1104
+ expect(File).to exist(File.join(tmpdir, 'en.lproj', 'Localizable.strings'))
1105
+ end
1106
+ end
1107
+
1108
+ it 'creates LocalizableConstants.swift by default' do
1109
+ Dir.mktmpdir do |tmpdir|
1110
+ Dir.chdir(tmpdir) { SwiftWriter.write(languages, terms, tmpdir, :smart, options) }
1111
+ expect(File).to exist(File.join(tmpdir, 'LocalizableConstants.swift'))
1112
+ end
1113
+ end
1114
+
1115
+ it 'skips LocalizableConstants.swift when create_constants is false' do
1116
+ Dir.mktmpdir do |tmpdir|
1117
+ Dir.chdir(tmpdir) { SwiftWriter.write(languages, terms, tmpdir, :smart, options.merge(create_constants: false)) }
1118
+ expect(File).not_to exist(File.join(tmpdir, 'LocalizableConstants.swift'))
1119
+ end
1120
+ end
1121
+ end
1122
+ end
1123
+ ```
1124
+
1125
+ **Step 3: Run tests**
1126
+
1127
+ Run: `bundle exec rspec spec/localio/writers/ios_writer_spec.rb spec/localio/writers/swift_writer_spec.rb`
1128
+ Expected: all pass
1129
+
1130
+ **Step 4: Commit**
1131
+
1132
+ ```bash
1133
+ git add spec/localio/writers/ios_writer_spec.rb spec/localio/writers/swift_writer_spec.rb
1134
+ git commit -m "test: IosWriter and SwiftWriter tests"
1135
+ ```
1136
+
1137
+ ---
1138
+
1139
+ ### Task 13: JsonWriter, RailsWriter, JavaPropertiesWriter, ResXWriter tests
1140
+
1141
+ **Files:**
1142
+ - Create: `spec/localio/writers/json_writer_spec.rb`
1143
+ - Create: `spec/localio/writers/rails_writer_spec.rb`
1144
+ - Create: `spec/localio/writers/java_properties_writer_spec.rb`
1145
+ - Create: `spec/localio/writers/resx_writer_spec.rb`
1146
+
1147
+ **Step 1: Write `spec/localio/writers/json_writer_spec.rb`**
1148
+
1149
+ ```ruby
1150
+ require 'json'
1151
+ require 'localio/string_helper'
1152
+ require 'localio/term'
1153
+ require 'localio/segment'
1154
+ require 'localio/segments_list_holder'
1155
+ require 'localio/formatter'
1156
+ require 'localio/template_handler'
1157
+ require 'localio/writers/json_writer'
1158
+
1159
+ RSpec.describe JsonWriter do
1160
+ include_context 'standard terms'
1161
+
1162
+ let(:options) { { default_language: 'en' } }
1163
+
1164
+ describe '.write' do
1165
+ it 'creates strings-{lang}.json for each language' do
1166
+ Dir.mktmpdir do |tmpdir|
1167
+ Dir.chdir(tmpdir) { JsonWriter.write(languages, terms, tmpdir, :smart, options) }
1168
+ expect(File).to exist(File.join(tmpdir, 'strings-en.json'))
1169
+ expect(File).to exist(File.join(tmpdir, 'strings-es.json'))
1170
+ expect(File).to exist(File.join(tmpdir, 'strings-fr.json'))
1171
+ end
1172
+ end
1173
+
1174
+ it 'produces valid JSON' do
1175
+ Dir.mktmpdir do |tmpdir|
1176
+ Dir.chdir(tmpdir) { JsonWriter.write(languages, terms, tmpdir, :smart, options) }
1177
+ content = File.read(File.join(tmpdir, 'strings-en.json'))
1178
+ expect { JSON.parse(content) }.not_to raise_error
1179
+ end
1180
+ end
1181
+
1182
+ it 'includes translation values' do
1183
+ Dir.mktmpdir do |tmpdir|
1184
+ Dir.chdir(tmpdir) { JsonWriter.write(languages, terms, tmpdir, :smart, options) }
1185
+ data = JSON.parse(File.read(File.join(tmpdir, 'strings-en.json')))
1186
+ expect(data['translations']['app_name']).to eq('My App')
1187
+ end
1188
+ end
1189
+
1190
+ it 'sets correct language in meta' do
1191
+ Dir.mktmpdir do |tmpdir|
1192
+ Dir.chdir(tmpdir) { JsonWriter.write(languages, terms, tmpdir, :smart, options) }
1193
+ data = JSON.parse(File.read(File.join(tmpdir, 'strings-es.json')))
1194
+ expect(data['meta']['language']).to eq('es')
1195
+ end
1196
+ end
1197
+ end
1198
+ end
1199
+ ```
1200
+
1201
+ **Step 2: Write `spec/localio/writers/rails_writer_spec.rb`**
1202
+
1203
+ ```ruby
1204
+ require 'localio/string_helper'
1205
+ require 'localio/term'
1206
+ require 'localio/segment'
1207
+ require 'localio/segments_list_holder'
1208
+ require 'localio/formatter'
1209
+ require 'localio/template_handler'
1210
+ require 'localio/writers/rails_writer'
1211
+
1212
+ RSpec.describe RailsWriter do
1213
+ include_context 'standard terms'
1214
+
1215
+ let(:options) { { default_language: 'en' } }
1216
+
1217
+ describe '.write' do
1218
+ it 'creates {lang}.yml for each language' do
1219
+ Dir.mktmpdir do |tmpdir|
1220
+ Dir.chdir(tmpdir) { RailsWriter.write(languages, terms, tmpdir, :smart, options) }
1221
+ expect(File).to exist(File.join(tmpdir, 'en.yml'))
1222
+ expect(File).to exist(File.join(tmpdir, 'es.yml'))
1223
+ expect(File).to exist(File.join(tmpdir, 'fr.yml'))
1224
+ end
1225
+ end
1226
+
1227
+ it 'starts YAML with the language key' do
1228
+ Dir.mktmpdir do |tmpdir|
1229
+ Dir.chdir(tmpdir) { RailsWriter.write(languages, terms, tmpdir, :smart, options) }
1230
+ content = File.read(File.join(tmpdir, 'en.yml'))
1231
+ expect(content).to include('en:')
1232
+ expect(content).to include('app_name: "My App"')
1233
+ end
1234
+ end
1235
+
1236
+ it 'renders comment rows as YAML comments' do
1237
+ Dir.mktmpdir do |tmpdir|
1238
+ Dir.chdir(tmpdir) { RailsWriter.write(languages, terms, tmpdir, :smart, options) }
1239
+ content = File.read(File.join(tmpdir, 'en.yml'))
1240
+ expect(content).to include('# Section General')
1241
+ end
1242
+ end
1243
+ end
1244
+ end
1245
+ ```
1246
+
1247
+ **Step 3: Write `spec/localio/writers/java_properties_writer_spec.rb`**
1248
+
1249
+ ```ruby
1250
+ require 'localio/string_helper'
1251
+ require 'localio/term'
1252
+ require 'localio/segment'
1253
+ require 'localio/segments_list_holder'
1254
+ require 'localio/formatter'
1255
+ require 'localio/template_handler'
1256
+ require 'localio/writers/java_properties_writer'
1257
+
1258
+ RSpec.describe JavaPropertiesWriter do
1259
+ include_context 'standard terms'
1260
+
1261
+ let(:options) { { default_language: 'en' } }
1262
+
1263
+ describe '.write' do
1264
+ it 'creates language_{lang}.properties for each language' do
1265
+ Dir.mktmpdir do |tmpdir|
1266
+ Dir.chdir(tmpdir) { JavaPropertiesWriter.write(languages, terms, tmpdir, :smart, options) }
1267
+ expect(File).to exist(File.join(tmpdir, 'language_en.properties'))
1268
+ expect(File).to exist(File.join(tmpdir, 'language_es.properties'))
1269
+ end
1270
+ end
1271
+
1272
+ it 'includes translation values' do
1273
+ Dir.mktmpdir do |tmpdir|
1274
+ Dir.chdir(tmpdir) { JavaPropertiesWriter.write(languages, terms, tmpdir, :smart, options) }
1275
+ content = File.read(File.join(tmpdir, 'language_en.properties'))
1276
+ expect(content).to include('My App')
1277
+ end
1278
+ end
1279
+ end
1280
+ end
1281
+ ```
1282
+
1283
+ **Step 4: Write `spec/localio/writers/resx_writer_spec.rb`**
1284
+
1285
+ ```ruby
1286
+ require 'localio/string_helper'
1287
+ require 'localio/term'
1288
+ require 'localio/segment'
1289
+ require 'localio/segments_list_holder'
1290
+ require 'localio/formatter'
1291
+ require 'localio/template_handler'
1292
+ require 'localio/writers/resx_writer'
1293
+
1294
+ RSpec.describe ResXWriter do
1295
+ include_context 'standard terms'
1296
+
1297
+ let(:options) { { default_language: 'en' } }
1298
+
1299
+ describe '.write' do
1300
+ it 'creates Resources.resx for the default language' do
1301
+ Dir.mktmpdir do |tmpdir|
1302
+ Dir.chdir(tmpdir) { ResXWriter.write(languages, terms, tmpdir, :smart, options) }
1303
+ expect(File).to exist(File.join(tmpdir, 'Resources.resx'))
1304
+ end
1305
+ end
1306
+
1307
+ it 'creates Resources.{lang}.resx for non-default languages' do
1308
+ Dir.mktmpdir do |tmpdir|
1309
+ Dir.chdir(tmpdir) { ResXWriter.write(languages, terms, tmpdir, :smart, options) }
1310
+ expect(File).to exist(File.join(tmpdir, 'Resources.es.resx'))
1311
+ expect(File).to exist(File.join(tmpdir, 'Resources.fr.resx'))
1312
+ end
1313
+ end
1314
+
1315
+ it 'uses custom resource_file name when specified' do
1316
+ Dir.mktmpdir do |tmpdir|
1317
+ Dir.chdir(tmpdir) { ResXWriter.write(languages, terms, tmpdir, :smart, options.merge(resource_file: 'Strings')) }
1318
+ expect(File).to exist(File.join(tmpdir, 'Strings.resx'))
1319
+ expect(File).to exist(File.join(tmpdir, 'Strings.es.resx'))
1320
+ end
1321
+ end
1322
+ end
1323
+ end
1324
+ ```
1325
+
1326
+ **Step 5: Run all writer tests**
1327
+
1328
+ Run: `bundle exec rspec spec/localio/writers/`
1329
+ Expected: all pass
1330
+
1331
+ **Step 6: Commit**
1332
+
1333
+ ```bash
1334
+ git add spec/localio/writers/json_writer_spec.rb spec/localio/writers/rails_writer_spec.rb spec/localio/writers/java_properties_writer_spec.rb spec/localio/writers/resx_writer_spec.rb
1335
+ git commit -m "test: Json, Rails, JavaProperties, and ResX writer tests"
1336
+ ```
1337
+
1338
+ ---
1339
+
1340
+ ### Task 14: Integration pipeline test
1341
+
1342
+ **Files:**
1343
+ - Create: `spec/localio_spec.rb`
1344
+
1345
+ **Step 1: Write test**
1346
+
1347
+ ```ruby
1348
+ require 'json'
1349
+ require 'localio/string_helper'
1350
+ require 'localio/term'
1351
+ require 'localio/segment'
1352
+ require 'localio/segments_list_holder'
1353
+ require 'localio/filter'
1354
+ require 'localio/formatter'
1355
+ require 'localio/template_handler'
1356
+ require 'localio/processors/csv_processor'
1357
+ require 'localio/writers/android_writer'
1358
+ require 'localio/writers/json_writer'
1359
+
1360
+ RSpec.describe 'Localio pipeline' do
1361
+ let(:fixture_path) { File.expand_path('fixtures/sample.csv', __dir__) }
1362
+
1363
+ it 'processes CSV and writes Android strings.xml with correct transformations' do
1364
+ Dir.mktmpdir do |tmpdir|
1365
+ Dir.chdir(tmpdir) do
1366
+ result = CsvProcessor.load_localizables({}, { path: fixture_path })
1367
+ AndroidWriter.write(result[:languages], result[:segments], tmpdir, :smart,
1368
+ { default_language: result[:default_language] })
1369
+
1370
+ content = File.read(File.join(tmpdir, 'values', 'strings.xml'))
1371
+ expect(content).to include('<string name="app_name">My App</string>')
1372
+ expect(content).to include('&amp; Jerry') # & converted
1373
+ expect(content).to include('Wait…') # ... converted
1374
+ expect(content).to include('%s world') # %@ converted
1375
+ expect(content).to include('<!-- Section General -->')
1376
+ end
1377
+ end
1378
+ end
1379
+
1380
+ it 'processes CSV and writes valid JSON with correct content' do
1381
+ Dir.mktmpdir do |tmpdir|
1382
+ Dir.chdir(tmpdir) do
1383
+ result = CsvProcessor.load_localizables({}, { path: fixture_path })
1384
+ JsonWriter.write(result[:languages], result[:segments], tmpdir, :smart,
1385
+ { default_language: result[:default_language] })
1386
+
1387
+ data = JSON.parse(File.read(File.join(tmpdir, 'strings-en.json')))
1388
+ expect(data['translations']['app_name']).to eq('My App')
1389
+ expect(data['meta']['language']).to eq('en')
1390
+ end
1391
+ end
1392
+ end
1393
+
1394
+ it 'applies only filter before writing' do
1395
+ result = CsvProcessor.load_localizables({}, { path: fixture_path })
1396
+ filtered = Filter.apply_filter(result[:segments], { keys: 'app_' }, nil)
1397
+ expect(filtered.map(&:keyword)).to eq(['app_name'])
1398
+ end
1399
+
1400
+ it 'applies except filter before writing' do
1401
+ result = CsvProcessor.load_localizables({}, { path: fixture_path })
1402
+ filtered = Filter.apply_filter(result[:segments], nil, { keys: 'dots_|ampersand_' })
1403
+ expect(filtered.map(&:keyword)).not_to include('dots_test', 'ampersand_test')
1404
+ end
1405
+ end
1406
+ ```
1407
+
1408
+ **Step 2: Run full test suite**
1409
+
1410
+ Run: `bundle exec rspec`
1411
+ Expected: all tests pass, or failures are clearly gem-install issues (note for Phase 2)
1412
+
1413
+ **Step 3: Commit**
1414
+
1415
+ ```bash
1416
+ git add spec/localio_spec.rb
1417
+ git commit -m "test: integration pipeline test (CSV → writers)"
1418
+ ```
1419
+
1420
+ ---
1421
+
1422
+ ## Phase 2: Dependency Updates
1423
+
1424
+ ### Task 15: Audit gem compatibility
1425
+
1426
+ **Step 1: Attempt full install and check what breaks**
1427
+
1428
+ Run: `bundle install 2>&1`
1429
+
1430
+ Run: `bundle exec ruby -e "require 'google_drive'" 2>&1`
1431
+ Run: `bundle exec ruby -e "require 'simple_xlsx_reader'" 2>&1`
1432
+ Run: `bundle exec ruby -e "require 'spreadsheet'" 2>&1`
1433
+ Run: `bundle exec ruby -e "require 'micro-optparse'" 2>&1`
1434
+
1435
+ Run: `bundle exec rspec --format progress 2>&1 | tail -20`
1436
+
1437
+ Note all failures. These are the targets for the remaining tasks.
1438
+
1439
+ ---
1440
+
1441
+ ### Task 16: Update gemspec for Ruby 3.x
1442
+
1443
+ **Files:**
1444
+ - Modify: `localio.gemspec`
1445
+
1446
+ **Step 1: Replace gemspec content**
1447
+
1448
+ ```ruby
1449
+ # coding: utf-8
1450
+ lib = File.expand_path('../lib', __FILE__)
1451
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
1452
+ require 'localio/version'
1453
+
1454
+ Gem::Specification.new do |spec|
1455
+ spec.name = "localio"
1456
+ spec.version = Localio::VERSION
1457
+ spec.authors = ["Nacho Lopez"]
1458
+ spec.email = ["nacho@nlopez.io"]
1459
+ spec.description = %q{Automatic Localizable file generation for multiple platforms}
1460
+ spec.summary = %q{Generates Android, iOS, Rails, JSON, Java Properties, and .NET ResX localization files from spreadsheet sources.}
1461
+ spec.homepage = "http://github.com/mrmans0n/localio"
1462
+ spec.license = "MIT"
1463
+
1464
+ spec.files = `git ls-files`.split($/)
1465
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
1466
+ spec.require_paths = ["lib"]
1467
+ spec.executables << "localize"
1468
+
1469
+ spec.required_ruby_version = ">= 3.0"
1470
+
1471
+ spec.add_development_dependency "rspec", "~> 3.0"
1472
+ spec.add_development_dependency "bundler", "~> 2.0"
1473
+ spec.add_development_dependency "rake"
1474
+
1475
+ spec.add_dependency "google_drive", "~> 3.0"
1476
+ spec.add_dependency "spreadsheet", "~> 1.3"
1477
+ spec.add_dependency "simple_xlsx_reader", "~> 2.0"
1478
+ spec.add_dependency "nokogiri", "~> 1.16"
1479
+ end
1480
+ ```
1481
+
1482
+ Note: `micro-optparse` is removed — `bin/localize` will use stdlib `optparse` (Task 17).
1483
+
1484
+ **Step 2: Update bundle**
1485
+
1486
+ Run: `bundle update`
1487
+ Expected: updated Gemfile.lock
1488
+
1489
+ **Step 3: Run tests and note failures**
1490
+
1491
+ Run: `bundle exec rspec --format progress 2>&1 | tail -30`
1492
+
1493
+ **Step 4: Commit**
1494
+
1495
+ ```bash
1496
+ git add localio.gemspec Gemfile.lock
1497
+ git commit -m "chore: update gemspec to Ruby 3.x, modernize dependencies"
1498
+ ```
1499
+
1500
+ ---
1501
+
1502
+ ### Task 17: Replace micro-optparse with stdlib optparse
1503
+
1504
+ **Files:**
1505
+ - Modify: `bin/localize`
1506
+
1507
+ **Step 1: Read current `bin/localize`**
1508
+
1509
+ Run: `cat bin/localize`
1510
+
1511
+ **Step 2: Replace with stdlib optparse**
1512
+
1513
+ ```ruby
1514
+ #!/usr/bin/env ruby
1515
+ require 'optparse'
1516
+ require 'localio'
1517
+
1518
+ OptionParser.new do |opts|
1519
+ opts.banner = 'Usage: localize [Locfile]'
1520
+ opts.on('-v', '--version', 'Show version') do
1521
+ require 'localio/version'
1522
+ puts Localio::VERSION
1523
+ exit
1524
+ end
1525
+ end.parse!
1526
+
1527
+ Localio.from_cmdline(ARGV)
1528
+ ```
1529
+
1530
+ **Step 3: Run tests**
1531
+
1532
+ Run: `bundle exec rspec`
1533
+ Expected: no new failures
1534
+
1535
+ **Step 4: Commit**
1536
+
1537
+ ```bash
1538
+ git add bin/localize
1539
+ git commit -m "chore: replace micro-optparse with stdlib optparse"
1540
+ ```
1541
+
1542
+ ---
1543
+
1544
+ ### Task 18: Fix google_drive 3.x API changes
1545
+
1546
+ **Files:**
1547
+ - Modify: `lib/localio/processors/google_drive_processor.rb`
1548
+
1549
+ The google_drive 3.x API changed significantly:
1550
+ - No more `Google::APIClient` — uses `googleauth` gem
1551
+ - `GoogleDrive.login_with_oauth(token)` → `GoogleDrive::Session.from_access_token(token)` or `from_credentials`
1552
+
1553
+ **Step 1: Read the new API**
1554
+
1555
+ Run: `bundle exec ruby -e "require 'google_drive'; puts GoogleDrive::Session.methods.sort"`
1556
+
1557
+ **Step 2: Update the authentication block**
1558
+
1559
+ Replace the `begin...rescue` auth block (lines 30–77 in the original) with google_drive 3.x compatible code:
1560
+
1561
+ ```ruby
1562
+ puts 'Logging in to Google Drive...'
1563
+ begin
1564
+ config = ConfigStore.new
1565
+
1566
+ if options[:client_token]
1567
+ session = GoogleDrive::Session.from_service_account_key(options[:client_token])
1568
+ elsif config.has?(:refresh_token)
1569
+ session = GoogleDrive::Session.from_config_with_credentials(
1570
+ client_id: client_id,
1571
+ client_secret: client_secret,
1572
+ refresh_token: config.get(:refresh_token)
1573
+ )
1574
+ else
1575
+ session = GoogleDrive::Session.from_config_with_credentials(
1576
+ client_id: client_id,
1577
+ client_secret: client_secret
1578
+ ) do |url|
1579
+ puts "1. Open this page:\n#{url}\n"
1580
+ puts '2. Enter the authorization code: '
1581
+ $stdin.gets.chomp
1582
+ end
1583
+ config.store(:refresh_token, session.refresh_token)
1584
+ config.persist
1585
+ end
1586
+ rescue => e
1587
+ raise "Couldn't access Google Drive: #{e.message}"
1588
+ end
1589
+ ```
1590
+
1591
+ Adjust method names based on what `GoogleDrive::Session.methods` shows in Step 1.
1592
+
1593
+ **Step 3: Update the GoogleDriveProcessor spec mock**
1594
+
1595
+ In `spec/localio/processors/google_drive_processor_spec.rb`, update the `before` block to mock `GoogleDrive::Session` instead of `Google::APIClient` and `GoogleDrive.login_with_oauth`.
1596
+
1597
+ **Step 4: Run processor spec**
1598
+
1599
+ Run: `bundle exec rspec spec/localio/processors/google_drive_processor_spec.rb`
1600
+ Expected: all pass
1601
+
1602
+ **Step 5: Commit**
1603
+
1604
+ ```bash
1605
+ git add lib/localio/processors/google_drive_processor.rb spec/localio/processors/google_drive_processor_spec.rb
1606
+ git commit -m "fix: update GoogleDriveProcessor for google_drive 3.x API"
1607
+ ```
1608
+
1609
+ ---
1610
+
1611
+ ### Task 19: Fix simple_xlsx_reader 2.x API changes (if any)
1612
+
1613
+ **Files:**
1614
+ - Modify: `lib/localio/processors/xlsx_processor.rb` (if needed)
1615
+
1616
+ **Step 1: Run the spec and identify failures**
1617
+
1618
+ Run: `bundle exec rspec spec/localio/processors/xlsx_processor_spec.rb -f d`
1619
+
1620
+ **Step 2: Check changelog for breaking changes**
1621
+
1622
+ Run: `gem contents simple_xlsx_reader | xargs grep -l CHANGE 2>/dev/null | head -1 | xargs cat`
1623
+
1624
+ Common 2.x change: `SimpleXlsxReader.open(path)` may return a different object. The `book.sheets` array interface is usually stable but sheet row access may differ.
1625
+
1626
+ **Step 3: Fix any broken API calls in `xlsx_processor.rb`**
1627
+
1628
+ Update the mock in the spec to match the actual 2.x interface if needed.
1629
+
1630
+ **Step 4: Run tests**
1631
+
1632
+ Run: `bundle exec rspec spec/localio/processors/xlsx_processor_spec.rb`
1633
+ Expected: all pass
1634
+
1635
+ **Step 5: Commit**
1636
+
1637
+ ```bash
1638
+ git add lib/localio/processors/xlsx_processor.rb
1639
+ git commit -m "fix: update XlsxProcessor for simple_xlsx_reader 2.x"
1640
+ ```
1641
+
1642
+ ---
1643
+
1644
+ ### Task 20: Final Ruby 3.x compatibility sweep
1645
+
1646
+ **Step 1: Run full suite and list all failures**
1647
+
1648
+ Run: `bundle exec rspec --format documentation 2>&1 | grep -E "FAILED|Error" | head -30`
1649
+
1650
+ **Common Ruby 3.x issues to look for:**
1651
+
1652
+ - `Hash.new('default')` used as a hash with string default value — the `languages = Hash.new('languages')` in processors may cause unexpected behavior when missing keys return `'languages'` instead of nil. This is existing code behaviour, not a breakage, but verify tests still pass.
1653
+ - Keyword argument separation: methods that used hash splatting (`**`) — check if any `options` hash calls trigger the "last hash argument" warning.
1654
+ - `$LOAD_PATH` and encoding — usually fine in 3.x.
1655
+ - `String#encode` changes — watch for encoding errors in CSV/XLS parsing with non-ASCII characters (the `Mi Aplicación` fixture tests this).
1656
+
1657
+ **Step 2: Fix any remaining failures one by one**
1658
+
1659
+ For each failure, read the error, identify the cause, fix minimally.
1660
+
1661
+ **Step 3: Run full suite — must be green**
1662
+
1663
+ Run: `bundle exec rspec`
1664
+ Expected: all tests pass
1665
+
1666
+ **Step 4: Commit any fixes**
1667
+
1668
+ ```bash
1669
+ git add -p
1670
+ git commit -m "fix: Ruby 3.x compatibility fixes"
1671
+ ```
1672
+
1673
+ ---
1674
+
1675
+ ### Task 21: Version bump and final verification
1676
+
1677
+ **Files:**
1678
+ - Modify: `lib/localio/version.rb`
1679
+ - Modify: `README.md` (Ruby version requirement)
1680
+
1681
+ **Step 1: Bump the patch version**
1682
+
1683
+ In `lib/localio/version.rb`, change `VERSION = "0.1.7"` to `VERSION = "0.1.8"`.
1684
+
1685
+ **Step 2: Update README**
1686
+
1687
+ Find the Ruby version requirement mention in `README.md` and update it from `>= 1.9.2` to `>= 3.0`.
1688
+
1689
+ **Step 3: Final full suite run**
1690
+
1691
+ Run: `bundle exec rspec --format documentation`
1692
+ Expected: all tests documented and passing
1693
+
1694
+ **Step 4: Commit**
1695
+
1696
+ ```bash
1697
+ git add lib/localio/version.rb README.md
1698
+ git commit -m "chore: bump version to 0.1.8, require Ruby 3.0+"
1699
+ ```