better_translate 0.5.0 → 1.0.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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +14 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/.yardopts +10 -0
  6. data/CHANGELOG.md +125 -114
  7. data/CLAUDE.md +385 -0
  8. data/README.md +629 -244
  9. data/Rakefile +7 -1
  10. data/Steepfile +29 -0
  11. data/docs/implementation/00-overview.md +220 -0
  12. data/docs/implementation/01-setup_dependencies.md +668 -0
  13. data/docs/implementation/02-error_handling.md +65 -0
  14. data/docs/implementation/03-core_components.md +457 -0
  15. data/docs/implementation/03.5-variable_preservation.md +509 -0
  16. data/docs/implementation/04-provider_architecture.md +571 -0
  17. data/docs/implementation/05-translation_logic.md +1065 -0
  18. data/docs/implementation/06-main_module_api.md +122 -0
  19. data/docs/implementation/07-direct_translation_helpers.md +582 -0
  20. data/docs/implementation/08-rails_integration.md +323 -0
  21. data/docs/implementation/09-testing_suite.md +228 -0
  22. data/docs/implementation/10-documentation_examples.md +150 -0
  23. data/docs/implementation/11-quality_security.md +65 -0
  24. data/docs/implementation/12-cli_standalone.md +698 -0
  25. data/exe/better_translate +9 -0
  26. data/lib/better_translate/cache.rb +125 -0
  27. data/lib/better_translate/cli.rb +304 -0
  28. data/lib/better_translate/configuration.rb +201 -0
  29. data/lib/better_translate/direct_translator.rb +131 -0
  30. data/lib/better_translate/errors.rb +101 -0
  31. data/lib/better_translate/progress_tracker.rb +157 -0
  32. data/lib/better_translate/provider_factory.rb +45 -0
  33. data/lib/better_translate/providers/anthropic_provider.rb +154 -0
  34. data/lib/better_translate/providers/base_http_provider.rb +239 -0
  35. data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
  36. data/lib/better_translate/providers/gemini_provider.rb +123 -61
  37. data/lib/better_translate/railtie.rb +18 -0
  38. data/lib/better_translate/rate_limiter.rb +90 -0
  39. data/lib/better_translate/strategies/base_strategy.rb +58 -0
  40. data/lib/better_translate/strategies/batch_strategy.rb +56 -0
  41. data/lib/better_translate/strategies/deep_strategy.rb +45 -0
  42. data/lib/better_translate/strategies/strategy_selector.rb +43 -0
  43. data/lib/better_translate/translator.rb +115 -284
  44. data/lib/better_translate/utils/hash_flattener.rb +104 -0
  45. data/lib/better_translate/validator.rb +105 -0
  46. data/lib/better_translate/variable_extractor.rb +259 -0
  47. data/lib/better_translate/version.rb +2 -9
  48. data/lib/better_translate/yaml_handler.rb +168 -0
  49. data/lib/better_translate.rb +97 -73
  50. data/lib/generators/better_translate/analyze/USAGE +12 -0
  51. data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
  52. data/lib/generators/better_translate/install/USAGE +13 -0
  53. data/lib/generators/better_translate/install/install_generator.rb +71 -0
  54. data/lib/generators/better_translate/install/templates/README +20 -0
  55. data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
  56. data/lib/generators/better_translate/translate/USAGE +13 -0
  57. data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
  58. data/lib/tasks/better_translate.rake +136 -0
  59. data/sig/better_translate/cache.rbs +28 -0
  60. data/sig/better_translate/cli.rbs +24 -0
  61. data/sig/better_translate/configuration.rbs +78 -0
  62. data/sig/better_translate/direct_translator.rbs +18 -0
  63. data/sig/better_translate/errors.rbs +46 -0
  64. data/sig/better_translate/progress_tracker.rbs +29 -0
  65. data/sig/better_translate/provider_factory.rbs +8 -0
  66. data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
  67. data/sig/better_translate/providers/base_http_provider.rbs +44 -0
  68. data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
  69. data/sig/better_translate/providers/gemini_provider.rbs +22 -0
  70. data/sig/better_translate/railtie.rbs +7 -0
  71. data/sig/better_translate/rate_limiter.rbs +20 -0
  72. data/sig/better_translate/strategies/base_strategy.rbs +19 -0
  73. data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
  74. data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
  75. data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
  76. data/sig/better_translate/translator.rbs +24 -0
  77. data/sig/better_translate/utils/hash_flattener.rbs +14 -0
  78. data/sig/better_translate/validator.rbs +14 -0
  79. data/sig/better_translate/variable_extractor.rbs +40 -0
  80. data/sig/better_translate/version.rbs +4 -0
  81. data/sig/better_translate/yaml_handler.rbs +29 -0
  82. data/sig/better_translate.rbs +32 -2
  83. data/sig/faraday.rbs +22 -0
  84. data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
  85. data/sig/generators/better_translate/install/install_generator.rbs +14 -0
  86. data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
  87. data/sig/optparse.rbs +9 -0
  88. data/sig/psych.rbs +5 -0
  89. data/sig/rails.rbs +34 -0
  90. metadata +89 -203
  91. data/lib/better_translate/helper.rb +0 -83
  92. data/lib/better_translate/providers/base_provider.rb +0 -102
  93. data/lib/better_translate/service.rb +0 -144
  94. data/lib/better_translate/similarity_analyzer.rb +0 -218
  95. data/lib/better_translate/utils.rb +0 -55
  96. data/lib/better_translate/writer.rb +0 -75
  97. data/lib/generators/better_translate/analyze_generator.rb +0 -57
  98. data/lib/generators/better_translate/install_generator.rb +0 -14
  99. data/lib/generators/better_translate/templates/better_translate.rb +0 -56
  100. data/lib/generators/better_translate/translate_generator.rb +0 -84
@@ -0,0 +1,1065 @@
1
+ # 05 - Translation Logic
2
+
3
+ [← Previous: 04-Provider Architecture](./04-provider_architecture.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 06-Main Module Api →](./06-main_module_api.md)
4
+
5
+ ---
6
+
7
+ ## Translation Logic
8
+
9
+ ### 5.1 `lib/better_translate/yaml_handler.rb`
10
+
11
+ ```ruby
12
+ # frozen_string_literal: true
13
+
14
+ require "yaml"
15
+
16
+ module BetterTranslate
17
+ # Handles YAML file operations
18
+ #
19
+ # - Reading and parsing YAML files
20
+ # - Writing YAML files with proper formatting
21
+ # - Merging translations (incremental mode)
22
+ # - Handling exclusions
23
+ # - Flattening/unflattening nested structures
24
+ class YAMLHandler
25
+ # @return [Configuration] Configuration object
26
+ attr_reader :config
27
+
28
+ # Initialize YAML handler
29
+ #
30
+ # @param config [Configuration] Configuration object
31
+ def initialize(config)
32
+ @config = config
33
+ end
34
+
35
+ # Read and parse YAML file
36
+ #
37
+ # @param file_path [String] Path to YAML file
38
+ # @return [Hash] Parsed YAML content
39
+ # @raise [FileError] if file cannot be read
40
+ # @raise [YamlError] if YAML is invalid
41
+ def read_yaml(file_path)
42
+ Validator.validate_file_exists!(file_path)
43
+
44
+ content = File.read(file_path)
45
+ YAML.safe_load(content) || {}
46
+ rescue Errno::ENOENT => e
47
+ raise FileError.new("File not found: #{file_path}", context: { error: e.message })
48
+ rescue Psych::SyntaxError => e
49
+ raise YamlError.new("Invalid YAML syntax in #{file_path}", context: { error: e.message })
50
+ end
51
+
52
+ # Write hash to YAML file
53
+ #
54
+ # @param file_path [String] Output file path
55
+ # @param data [Hash] Data to write
56
+ # @return [void]
57
+ # @raise [FileError] if file cannot be written
58
+ def write_yaml(file_path, data)
59
+ return if config.dry_run
60
+
61
+ # Ensure output directory exists
62
+ FileUtils.mkdir_p(File.dirname(file_path))
63
+
64
+ File.write(file_path, YAML.dump(data))
65
+ rescue Errno::EACCES => e
66
+ raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
67
+ rescue StandardError => e
68
+ raise FileError.new("Failed to write YAML: #{file_path}", context: { error: e.message })
69
+ end
70
+
71
+ # Get translatable strings from source YAML
72
+ #
73
+ # @return [Hash] Flattened hash of translatable strings
74
+ def get_source_strings
75
+ source_data = read_yaml(config.input_file)
76
+ # Remove root language key if present (e.g., "en:")
77
+ source_data = source_data[config.source_language] || source_data
78
+
79
+ Utils::HashFlattener.flatten(source_data)
80
+ end
81
+
82
+ # Filter out excluded keys for a specific language
83
+ #
84
+ # @param strings [Hash] Flattened strings
85
+ # @param target_lang_code [String] Target language code
86
+ # @return [Hash] Filtered strings
87
+ def filter_exclusions(strings, target_lang_code)
88
+ excluded_keys = config.global_exclusions.dup
89
+ excluded_keys += config.exclusions_per_language[target_lang_code] || []
90
+
91
+ strings.reject { |key, _| excluded_keys.include?(key) }
92
+ end
93
+
94
+ # Merge translated strings with existing file (incremental mode)
95
+ #
96
+ # @param file_path [String] Existing file path
97
+ # @param new_translations [Hash] New translations (flattened)
98
+ # @return [Hash] Merged translations
99
+ def merge_translations(file_path, new_translations)
100
+ existing = File.exist?(file_path) ? read_yaml(file_path) : {}
101
+ existing_flat = Utils::HashFlattener.flatten(existing)
102
+
103
+ # Merge: existing takes precedence
104
+ merged = new_translations.merge(existing_flat)
105
+
106
+ Utils::HashFlattener.unflatten(merged)
107
+ end
108
+
109
+ # Build output file path for target language
110
+ #
111
+ # @param target_lang_code [String] Target language code
112
+ # @return [String] Output file path
113
+ def build_output_path(target_lang_code)
114
+ File.join(config.output_folder, "#{target_lang_code}.yml")
115
+ end
116
+ end
117
+ end
118
+ ```
119
+
120
+ ### 5.2 `lib/better_translate/strategies/base_strategy.rb`
121
+
122
+ ```ruby
123
+ # frozen_string_literal: true
124
+
125
+ module BetterTranslate
126
+ module Strategies
127
+ # Base class for translation strategies
128
+ #
129
+ # @abstract Subclasses must implement {#translate}
130
+ class BaseStrategy
131
+ # @return [Configuration] Configuration object
132
+ attr_reader :config
133
+
134
+ # @return [Providers::BaseHttpProvider] Translation provider
135
+ attr_reader :provider
136
+
137
+ # @return [ProgressTracker] Progress tracker
138
+ attr_reader :progress_tracker
139
+
140
+ # Initialize the strategy
141
+ #
142
+ # @param config [Configuration] Configuration object
143
+ # @param provider [Providers::BaseHttpProvider] Translation provider
144
+ # @param progress_tracker [ProgressTracker] Progress tracker
145
+ def initialize(config, provider, progress_tracker)
146
+ @config = config
147
+ @provider = provider
148
+ @progress_tracker = progress_tracker
149
+ end
150
+
151
+ # Translate strings
152
+ #
153
+ # @param strings [Hash] Flattened hash of strings to translate
154
+ # @param target_lang_code [String] Target language code
155
+ # @param target_lang_name [String] Target language name
156
+ # @return [Hash] Translated strings (flattened)
157
+ # @raise [NotImplementedError] Must be implemented by subclasses
158
+ def translate(strings, target_lang_code, target_lang_name)
159
+ raise NotImplementedError, "#{self.class} must implement #translate"
160
+ end
161
+ end
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### 5.3 `lib/better_translate/strategies/deep_strategy.rb`
167
+
168
+ ```ruby
169
+ # frozen_string_literal: true
170
+
171
+ module BetterTranslate
172
+ module Strategies
173
+ # Deep translation strategy
174
+ #
175
+ # Translates each string individually with detailed progress tracking.
176
+ # Used for smaller files (< 50 strings) to provide more granular progress.
177
+ class DeepStrategy < BaseStrategy
178
+ # Translate strings individually
179
+ #
180
+ # @param strings [Hash] Flattened hash of strings to translate
181
+ # @param target_lang_code [String] Target language code
182
+ # @param target_lang_name [String] Target language name
183
+ # @return [Hash] Translated strings (flattened)
184
+ def translate(strings, target_lang_code, target_lang_name)
185
+ translated = {}
186
+ total = strings.size
187
+
188
+ strings.each_with_index do |(key, value), index|
189
+ progress_tracker.update(
190
+ language: target_lang_name,
191
+ current_key: key,
192
+ progress: ((index + 1).to_f / total * 100).round(1)
193
+ )
194
+
195
+ translated[key] = provider.translate_text(value, target_lang_code, target_lang_name)
196
+ end
197
+
198
+ translated
199
+ end
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
205
+ ### 5.4 `lib/better_translate/strategies/batch_strategy.rb`
206
+
207
+ ```ruby
208
+ # frozen_string_literal: true
209
+
210
+ module BetterTranslate
211
+ module Strategies
212
+ # Batch translation strategy
213
+ #
214
+ # Translates strings in batches for improved performance.
215
+ # Used for larger files (>= 50 strings).
216
+ class BatchStrategy < BaseStrategy
217
+ BATCH_SIZE = 10
218
+
219
+ # Translate strings in batches
220
+ #
221
+ # @param strings [Hash] Flattened hash of strings to translate
222
+ # @param target_lang_code [String] Target language code
223
+ # @param target_lang_name [String] Target language name
224
+ # @return [Hash] Translated strings (flattened)
225
+ def translate(strings, target_lang_code, target_lang_name)
226
+ translated = {}
227
+ keys = strings.keys
228
+ values = strings.values
229
+ total_batches = (values.size.to_f / BATCH_SIZE).ceil
230
+
231
+ values.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
232
+ progress_tracker.update(
233
+ language: target_lang_name,
234
+ current_key: "Batch #{batch_index + 1}/#{total_batches}",
235
+ progress: ((batch_index + 1).to_f / total_batches * 100).round(1)
236
+ )
237
+
238
+ translated_batch = provider.translate_batch(batch, target_lang_code, target_lang_name)
239
+
240
+ # Map back to keys
241
+ batch_keys = keys[batch_index * BATCH_SIZE, batch.size]
242
+ batch_keys.each_with_index do |key, i|
243
+ translated[key] = translated_batch[i]
244
+ end
245
+ end
246
+
247
+ translated
248
+ end
249
+ end
250
+ end
251
+ end
252
+ ```
253
+
254
+ ### 5.5 `lib/better_translate/strategies/strategy_selector.rb`
255
+
256
+ ```ruby
257
+ # frozen_string_literal: true
258
+
259
+ module BetterTranslate
260
+ module Strategies
261
+ # Selects the appropriate translation strategy based on content size
262
+ class StrategySelector
263
+ DEEP_STRATEGY_THRESHOLD = 50
264
+
265
+ # Select the appropriate strategy
266
+ #
267
+ # @param strings_count [Integer] Number of strings to translate
268
+ # @param config [Configuration] Configuration object
269
+ # @param provider [Providers::BaseHttpProvider] Translation provider
270
+ # @param progress_tracker [ProgressTracker] Progress tracker
271
+ # @return [BaseStrategy] Selected strategy instance
272
+ def self.select(strings_count, config, provider, progress_tracker)
273
+ if strings_count < DEEP_STRATEGY_THRESHOLD
274
+ DeepStrategy.new(config, provider, progress_tracker)
275
+ else
276
+ BatchStrategy.new(config, provider, progress_tracker)
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### 5.6 `lib/better_translate/progress_tracker.rb`
285
+
286
+ ```ruby
287
+ # frozen_string_literal: true
288
+
289
+ module BetterTranslate
290
+ # Tracks and displays translation progress
291
+ #
292
+ # Shows real-time progress updates with colored console output.
293
+ class ProgressTracker
294
+ # @return [Boolean] Whether to show progress
295
+ attr_reader :enabled
296
+
297
+ # Initialize progress tracker
298
+ #
299
+ # @param enabled [Boolean] Whether to show progress (default: true)
300
+ def initialize(enabled: true)
301
+ @enabled = enabled
302
+ @start_time = Time.now
303
+ end
304
+
305
+ # Update progress
306
+ #
307
+ # @param language [String] Current language being translated
308
+ # @param current_key [String] Current translation key
309
+ # @param progress [Float] Progress percentage (0-100)
310
+ # @return [void]
311
+ def update(language:, current_key:, progress:)
312
+ return unless enabled
313
+
314
+ elapsed = Time.now - @start_time
315
+ estimated_total = elapsed / (progress / 100.0)
316
+ remaining = estimated_total - elapsed
317
+
318
+ message = format(
319
+ "\r[BetterTranslate] %s | %s | %.1f%% | Elapsed: %s | Remaining: ~%s",
320
+ colorize(language, :cyan),
321
+ truncate(current_key, 40),
322
+ progress,
323
+ format_time(elapsed),
324
+ format_time(remaining)
325
+ )
326
+
327
+ print message
328
+ $stdout.flush
329
+
330
+ puts "" if progress >= 100.0 # New line when complete
331
+ end
332
+
333
+ # Mark translation as complete for a language
334
+ #
335
+ # @param language [String] Language name
336
+ # @param total_strings [Integer] Total number of strings translated
337
+ # @return [void]
338
+ def complete(language, total_strings)
339
+ return unless enabled
340
+
341
+ elapsed = Time.now - @start_time
342
+ puts colorize("✓ #{language}: #{total_strings} strings translated in #{format_time(elapsed)}", :green)
343
+ end
344
+
345
+ # Display an error
346
+ #
347
+ # @param language [String] Language name
348
+ # @param error [StandardError] The error that occurred
349
+ # @return [void]
350
+ def error(language, error)
351
+ return unless enabled
352
+
353
+ puts colorize("✗ #{language}: #{error.message}", :red)
354
+ end
355
+
356
+ # Reset the progress tracker
357
+ #
358
+ # @return [void]
359
+ def reset
360
+ @start_time = Time.now
361
+ end
362
+
363
+ private
364
+
365
+ def format_time(seconds)
366
+ return "0s" if seconds <= 0
367
+
368
+ minutes = (seconds / 60).to_i
369
+ secs = (seconds % 60).to_i
370
+
371
+ if minutes > 0
372
+ "#{minutes}m #{secs}s"
373
+ else
374
+ "#{secs}s"
375
+ end
376
+ end
377
+
378
+ def truncate(text, max_length)
379
+ return text if text.length <= max_length
380
+
381
+ "#{text[0...(max_length - 3)]}..."
382
+ end
383
+
384
+ def colorize(text, color)
385
+ return text unless $stdout.tty?
386
+
387
+ colors = {
388
+ red: "\e[31m",
389
+ green: "\e[32m",
390
+ cyan: "\e[36m",
391
+ reset: "\e[0m"
392
+ }
393
+
394
+ "#{colors[color]}#{text}#{colors[:reset]}"
395
+ end
396
+ end
397
+ end
398
+ ```
399
+
400
+ ### 5.7 `lib/better_translate/translator.rb`
401
+
402
+ ```ruby
403
+ # frozen_string_literal: true
404
+
405
+ module BetterTranslate
406
+ # Main translator class
407
+ #
408
+ # Coordinates the translation process using configuration, providers,
409
+ # strategies, and YAML handling.
410
+ #
411
+ # @example Basic usage
412
+ # translator = Translator.new(config)
413
+ # results = translator.translate_all
414
+ #
415
+ class Translator
416
+ # @return [Configuration] Configuration object
417
+ attr_reader :config
418
+
419
+ # Initialize translator
420
+ #
421
+ # @param config [Configuration] Configuration object
422
+ def initialize(config)
423
+ @config = config
424
+ @config.validate!
425
+ @provider = ProviderFactory.create(config.provider, config)
426
+ @yaml_handler = YAMLHandler.new(config)
427
+ @progress_tracker = ProgressTracker.new(enabled: config.verbose)
428
+ end
429
+
430
+ # Translate to all target languages
431
+ #
432
+ # @return [Hash] Results hash with :success_count, :failure_count, :errors
433
+ def translate_all
434
+ source_strings = @yaml_handler.get_source_strings
435
+
436
+ results = {
437
+ success_count: 0,
438
+ failure_count: 0,
439
+ errors: []
440
+ }
441
+
442
+ config.target_languages.each do |lang|
443
+ begin
444
+ translate_language(source_strings, lang)
445
+ results[:success_count] += 1
446
+ rescue StandardError => e
447
+ results[:failure_count] += 1
448
+ results[:errors] << {
449
+ language: lang[:name],
450
+ error: e.message,
451
+ context: e.respond_to?(:context) ? e.context : {}
452
+ }
453
+ @progress_tracker.error(lang[:name], e)
454
+ end
455
+ end
456
+
457
+ results
458
+ end
459
+
460
+ private
461
+
462
+ def translate_language(source_strings, lang)
463
+ target_lang_code = lang[:short_name]
464
+ target_lang_name = lang[:name]
465
+
466
+ # Filter exclusions
467
+ strings_to_translate = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
468
+
469
+ return if strings_to_translate.empty?
470
+
471
+ # Select strategy
472
+ strategy = Strategies::StrategySelector.select(
473
+ strings_to_translate.size,
474
+ config,
475
+ @provider,
476
+ @progress_tracker
477
+ )
478
+
479
+ # Translate
480
+ @progress_tracker.reset
481
+ translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
482
+
483
+ # Save
484
+ output_path = @yaml_handler.build_output_path(target_lang_code)
485
+
486
+ if config.translation_mode == :incremental
487
+ final_translations = @yaml_handler.merge_translations(output_path, translated)
488
+ else
489
+ final_translations = Utils::HashFlattener.unflatten(translated)
490
+ end
491
+
492
+ # Wrap in language key (e.g., "it:")
493
+ wrapped = { target_lang_code => final_translations }
494
+ @yaml_handler.write_yaml(output_path, wrapped)
495
+
496
+ @progress_tracker.complete(target_lang_name, translated.size)
497
+ end
498
+ end
499
+ end
500
+ ```
501
+
502
+ ### 5.8 `lib/better_translate/diff_preview.rb`
503
+
504
+ ```ruby
505
+ # frozen_string_literal: true
506
+
507
+ module BetterTranslate
508
+ # Displays colored diff preview of translation changes
509
+ #
510
+ # Shows what will be added, modified, or removed before writing files.
511
+ # Works in conjunction with config.dry_run mode.
512
+ #
513
+ # @example
514
+ # preview = DiffPreview.new(config)
515
+ # preview.show_diff(existing_data, new_data, output_path)
516
+ # # Output:
517
+ # # === config/locales/it.yml ===
518
+ # # + welcome: "Benvenuto"
519
+ # # ~ greeting: "Ciao" (was: "Salve")
520
+ # # - old_key: "Vecchio valore"
521
+ #
522
+ class DiffPreview
523
+ # @return [Configuration] Configuration object
524
+ attr_reader :config
525
+
526
+ # Initialize diff preview
527
+ #
528
+ # @param config [Configuration] Configuration object
529
+ def initialize(config)
530
+ @config = config
531
+ end
532
+
533
+ # Show diff between existing and new data
534
+ #
535
+ # @param existing_data [Hash] Existing YAML data (nested)
536
+ # @param new_data [Hash] New YAML data (nested)
537
+ # @param output_path [String] Output file path
538
+ # @return [Hash] Summary with counts
539
+ def show_diff(existing_data, new_data, output_path)
540
+ return { added: 0, modified: 0, removed: 0 } unless config.dry_run
541
+
542
+ existing_flat = Utils::HashFlattener.flatten(existing_data)
543
+ new_flat = Utils::HashFlattener.flatten(new_data)
544
+
545
+ changes = calculate_changes(existing_flat, new_flat)
546
+
547
+ display_diff(output_path, changes)
548
+
549
+ {
550
+ added: changes[:added].size,
551
+ modified: changes[:modified].size,
552
+ removed: changes[:removed].size
553
+ }
554
+ end
555
+
556
+ # Show summary for all language files
557
+ #
558
+ # @param summaries [Array<Hash>] Array of summary hashes with :file, :added, :modified, :removed
559
+ # @return [void]
560
+ def show_summary(summaries)
561
+ return unless config.dry_run
562
+
563
+ puts "\n" + colorize("=" * 60, :cyan)
564
+ puts colorize("DRY RUN SUMMARY", :cyan)
565
+ puts colorize("=" * 60, :cyan)
566
+
567
+ total_added = 0
568
+ total_modified = 0
569
+ total_removed = 0
570
+
571
+ summaries.each do |summary|
572
+ total_added += summary[:added]
573
+ total_modified += summary[:modified]
574
+ total_removed += summary[:removed]
575
+
576
+ puts "\n#{summary[:file]}:"
577
+ puts " #{colorize('+', :green)} #{summary[:added]} added"
578
+ puts " #{colorize('~', :yellow)} #{summary[:modified]} modified"
579
+ puts " #{colorize('-', :red)} #{summary[:removed]} removed"
580
+ end
581
+
582
+ puts "\n" + colorize("-" * 60, :cyan)
583
+ puts "Total: #{colorize("+#{total_added}", :green)} | " \
584
+ "#{colorize("~#{total_modified}", :yellow)} | " \
585
+ "#{colorize("-#{total_removed}", :red)}"
586
+ puts colorize("=" * 60, :cyan)
587
+ puts "\n#{colorize('ℹ', :cyan)} No files were modified (dry run mode)"
588
+ end
589
+
590
+ private
591
+
592
+ # Calculate changes between existing and new data
593
+ #
594
+ # @param existing [Hash] Existing flattened data
595
+ # @param new_data [Hash] New flattened data
596
+ # @return [Hash] Changes with :added, :modified, :removed keys
597
+ def calculate_changes(existing, new_data)
598
+ added = []
599
+ modified = []
600
+ removed = []
601
+
602
+ # Find added and modified keys
603
+ new_data.each do |key, new_value|
604
+ if existing.key?(key)
605
+ if existing[key] != new_value
606
+ modified << { key: key, old_value: existing[key], new_value: new_value }
607
+ end
608
+ else
609
+ added << { key: key, value: new_value }
610
+ end
611
+ end
612
+
613
+ # Find removed keys
614
+ existing.each_key do |key|
615
+ removed << { key: key, value: existing[key] } unless new_data.key?(key)
616
+ end
617
+
618
+ { added: added, modified: modified, removed: removed }
619
+ end
620
+
621
+ # Display diff with colored output
622
+ #
623
+ # @param file_path [String] Output file path
624
+ # @param changes [Hash] Changes hash
625
+ # @return [void]
626
+ def display_diff(file_path, changes)
627
+ puts "\n" + colorize("=" * 60, :cyan)
628
+ puts colorize("#{file_path}", :cyan)
629
+ puts colorize("=" * 60, :cyan)
630
+
631
+ # Show added keys
632
+ changes[:added].each do |item|
633
+ puts colorize("+ #{item[:key]}: #{format_value(item[:value])}", :green)
634
+ end
635
+
636
+ # Show modified keys
637
+ changes[:modified].each do |item|
638
+ puts colorize("~ #{item[:key]}: #{format_value(item[:new_value])}", :yellow)
639
+ puts colorize(" (was: #{format_value(item[:old_value])})", :yellow)
640
+ end
641
+
642
+ # Show removed keys
643
+ changes[:removed].each do |item|
644
+ puts colorize("- #{item[:key]}: #{format_value(item[:value])}", :red)
645
+ end
646
+
647
+ # Show counts
648
+ puts colorize("-" * 60, :cyan)
649
+ puts "#{colorize('+', :green)} #{changes[:added].size} | " \
650
+ "#{colorize('~', :yellow)} #{changes[:modified].size} | " \
651
+ "#{colorize('-', :red)} #{changes[:removed].size}"
652
+ end
653
+
654
+ # Format value for display (truncate long strings)
655
+ #
656
+ # @param value [String] Value to format
657
+ # @return [String] Formatted value
658
+ def format_value(value)
659
+ max_length = 60
660
+ return value.inspect if value.length <= max_length
661
+
662
+ "#{value[0...(max_length - 3)].inspect}..."
663
+ end
664
+
665
+ # Colorize text for terminal output
666
+ #
667
+ # @param text [String] Text to colorize
668
+ # @param color [Symbol] Color name (:red, :green, :yellow, :cyan)
669
+ # @return [String] Colorized text
670
+ def colorize(text, color)
671
+ return text unless $stdout.tty?
672
+
673
+ colors = {
674
+ red: "\e[31m",
675
+ green: "\e[32m",
676
+ yellow: "\e[33m",
677
+ cyan: "\e[36m",
678
+ reset: "\e[0m"
679
+ }
680
+
681
+ "#{colors[color]}#{text}#{colors[:reset]}"
682
+ end
683
+ end
684
+ end
685
+ ```
686
+
687
+ ### 5.9 Update `YAMLHandler` to integrate DiffPreview
688
+
689
+ Update the `write_yaml` method in `lib/better_translate/yaml_handler.rb`:
690
+
691
+ ```ruby
692
+ # Write hash to YAML file with optional diff preview
693
+ #
694
+ # @param file_path [String] Output file path
695
+ # @param data [Hash] Data to write
696
+ # @param diff_preview [DiffPreview, nil] Optional diff preview instance
697
+ # @return [Hash, nil] Summary hash if dry_run, nil otherwise
698
+ # @raise [FileError] if file cannot be written
699
+ def write_yaml(file_path, data, diff_preview: nil)
700
+ summary = nil
701
+
702
+ # Show diff preview if in dry run mode
703
+ if config.dry_run && diff_preview
704
+ existing_data = File.exist?(file_path) ? read_yaml(file_path) : {}
705
+ summary = diff_preview.show_diff(existing_data, data, file_path)
706
+ end
707
+
708
+ return summary if config.dry_run
709
+
710
+ # Ensure output directory exists
711
+ FileUtils.mkdir_p(File.dirname(file_path))
712
+
713
+ File.write(file_path, YAML.dump(data))
714
+
715
+ nil
716
+ rescue Errno::EACCES => e
717
+ raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
718
+ rescue StandardError => e
719
+ raise FileError.new("Failed to write YAML: #{file_path}", context: { error: e.message })
720
+ end
721
+ ```
722
+
723
+ ### 5.10 Update `Translator` to use DiffPreview
724
+
725
+ Update `lib/better_translate/translator.rb` to integrate the diff preview:
726
+
727
+ ```ruby
728
+ # Initialize translator
729
+ #
730
+ # @param config [Configuration] Configuration object
731
+ def initialize(config)
732
+ @config = config
733
+ @config.validate!
734
+ @provider = ProviderFactory.create(config.provider, config)
735
+ @yaml_handler = YAMLHandler.new(config)
736
+ @progress_tracker = ProgressTracker.new(enabled: config.verbose)
737
+ @diff_preview = DiffPreview.new(config)
738
+ @diff_summaries = []
739
+ end
740
+
741
+ # Translate to all target languages
742
+ #
743
+ # @return [Hash] Results hash with :success_count, :failure_count, :errors
744
+ def translate_all
745
+ source_strings = @yaml_handler.get_source_strings
746
+
747
+ results = {
748
+ success_count: 0,
749
+ failure_count: 0,
750
+ errors: []
751
+ }
752
+
753
+ config.target_languages.each do |lang|
754
+ begin
755
+ summary = translate_language(source_strings, lang)
756
+ @diff_summaries << summary if summary
757
+ results[:success_count] += 1
758
+ rescue StandardError => e
759
+ results[:failure_count] += 1
760
+ results[:errors] << {
761
+ language: lang[:name],
762
+ error: e.message,
763
+ context: e.respond_to?(:context) ? e.context : {}
764
+ }
765
+ @progress_tracker.error(lang[:name], e)
766
+ end
767
+ end
768
+
769
+ # Show summary if dry run
770
+ @diff_preview.show_summary(@diff_summaries) if config.dry_run
771
+
772
+ results
773
+ end
774
+
775
+ private
776
+
777
+ def translate_language(source_strings, lang)
778
+ target_lang_code = lang[:short_name]
779
+ target_lang_name = lang[:name]
780
+
781
+ # Filter exclusions
782
+ strings_to_translate = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
783
+
784
+ return nil if strings_to_translate.empty?
785
+
786
+ # Select strategy
787
+ strategy = Strategies::StrategySelector.select(
788
+ strings_to_translate.size,
789
+ config,
790
+ @provider,
791
+ @progress_tracker
792
+ )
793
+
794
+ # Translate
795
+ @progress_tracker.reset
796
+ translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
797
+
798
+ # Save
799
+ output_path = @yaml_handler.build_output_path(target_lang_code)
800
+
801
+ if config.translation_mode == :incremental
802
+ final_translations = @yaml_handler.merge_translations(output_path, translated)
803
+ else
804
+ final_translations = Utils::HashFlattener.unflatten(translated)
805
+ end
806
+
807
+ # Wrap in language key (e.g., "it:")
808
+ wrapped = { target_lang_code => final_translations }
809
+
810
+ # Write YAML with diff preview
811
+ summary = @yaml_handler.write_yaml(output_path, wrapped, diff_preview: @diff_preview)
812
+
813
+ @progress_tracker.complete(target_lang_name, translated.size)
814
+
815
+ # Return summary for dry run
816
+ if summary
817
+ { file: output_path, **summary }
818
+ else
819
+ nil
820
+ end
821
+ end
822
+ ```
823
+
824
+ ### 5.11 Test: `spec/better_translate/diff_preview_spec.rb`
825
+
826
+ ```ruby
827
+ # frozen_string_literal: true
828
+
829
+ RSpec.describe BetterTranslate::DiffPreview do
830
+ let(:config) do
831
+ BetterTranslate::Configuration.new.tap do |c|
832
+ c.dry_run = true
833
+ end
834
+ end
835
+
836
+ subject(:diff_preview) { described_class.new(config) }
837
+
838
+ describe "#show_diff" do
839
+ context "when dry_run is enabled" do
840
+ it "shows added keys" do
841
+ existing = {}
842
+ new_data = { "en" => { "welcome" => "Welcome" } }
843
+
844
+ expect {
845
+ diff_preview.show_diff(existing, new_data, "en.yml")
846
+ }.to output(/\+ welcome: "Welcome"/).to_stdout
847
+ end
848
+
849
+ it "shows modified keys" do
850
+ existing = { "en" => { "welcome" => "Hello" } }
851
+ new_data = { "en" => { "welcome" => "Welcome" } }
852
+
853
+ expect {
854
+ diff_preview.show_diff(existing, new_data, "en.yml")
855
+ }.to output(/~ welcome: "Welcome"/).to_stdout
856
+ end
857
+
858
+ it "shows removed keys" do
859
+ existing = { "en" => { "old_key" => "Old value" } }
860
+ new_data = { "en" => {} }
861
+
862
+ expect {
863
+ diff_preview.show_diff(existing, new_data, "en.yml")
864
+ }.to output(/- old_key: "Old value"/).to_stdout
865
+ end
866
+
867
+ it "returns summary with counts" do
868
+ existing = {
869
+ "en" => {
870
+ "old_key" => "Old",
871
+ "modified" => "Original"
872
+ }
873
+ }
874
+ new_data = {
875
+ "en" => {
876
+ "modified" => "Changed",
877
+ "new_key" => "New"
878
+ }
879
+ }
880
+
881
+ summary = diff_preview.show_diff(existing, new_data, "en.yml")
882
+
883
+ expect(summary[:added]).to eq(1)
884
+ expect(summary[:modified]).to eq(1)
885
+ expect(summary[:removed]).to eq(1)
886
+ end
887
+ end
888
+
889
+ context "when dry_run is disabled" do
890
+ before { config.dry_run = false }
891
+
892
+ it "returns zero counts" do
893
+ existing = {}
894
+ new_data = { "en" => { "welcome" => "Welcome" } }
895
+
896
+ summary = diff_preview.show_diff(existing, new_data, "en.yml")
897
+
898
+ expect(summary[:added]).to eq(0)
899
+ expect(summary[:modified]).to eq(0)
900
+ expect(summary[:removed]).to eq(0)
901
+ end
902
+ end
903
+ end
904
+
905
+ describe "#show_summary" do
906
+ it "displays total summary for all files" do
907
+ summaries = [
908
+ { file: "it.yml", added: 5, modified: 2, removed: 1 },
909
+ { file: "fr.yml", added: 3, modified: 1, removed: 0 }
910
+ ]
911
+
912
+ expect {
913
+ diff_preview.show_summary(summaries)
914
+ }.to output(/Total: \+8 \| ~3 \| -1/).to_stdout
915
+ end
916
+
917
+ it "shows no files were modified message" do
918
+ summaries = [
919
+ { file: "it.yml", added: 5, modified: 0, removed: 0 }
920
+ ]
921
+
922
+ expect {
923
+ diff_preview.show_summary(summaries)
924
+ }.to output(/No files were modified \(dry run mode\)/).to_stdout
925
+ end
926
+ end
927
+
928
+ describe "#calculate_changes" do
929
+ it "identifies all types of changes" do
930
+ existing = {
931
+ "keep" => "Same",
932
+ "modify" => "Old",
933
+ "remove" => "Gone"
934
+ }
935
+ new_data = {
936
+ "keep" => "Same",
937
+ "modify" => "New",
938
+ "add" => "Fresh"
939
+ }
940
+
941
+ changes = diff_preview.send(:calculate_changes, existing, new_data)
942
+
943
+ expect(changes[:added].size).to eq(1)
944
+ expect(changes[:modified].size).to eq(1)
945
+ expect(changes[:removed].size).to eq(1)
946
+ end
947
+ end
948
+ end
949
+ ```
950
+
951
+ ---
952
+
953
+ ## Configuration Option
954
+
955
+ Add to `lib/better_translate/configuration.rb`:
956
+
957
+ ```ruby
958
+ # @return [Boolean] Dry run mode - show diff preview without writing files (default: false)
959
+ attr_accessor :dry_run
960
+
961
+ # In initialize method:
962
+ @dry_run = false
963
+ ```
964
+
965
+ ---
966
+
967
+ ## Usage Examples
968
+
969
+ ### Example 1: Enable Dry Run Mode
970
+
971
+ ```ruby
972
+ BetterTranslate.configure do |config|
973
+ config.dry_run = true
974
+ config.input_file = "config/locales/en.yml"
975
+ config.target_languages = [
976
+ { short_name: "it", name: "Italian" },
977
+ { short_name: "fr", name: "French" }
978
+ ]
979
+ end
980
+
981
+ BetterTranslate.translate_all
982
+ # Output:
983
+ # ============================================================
984
+ # config/locales/it.yml
985
+ # ============================================================
986
+ # + welcome: "Benvenuto"
987
+ # + greeting: "Ciao"
988
+ # ~ goodbye: "Arrivederci" (was: "Addio")
989
+ # - old_message: "Vecchio messaggio"
990
+ # ------------------------------------------------------------
991
+ # + 2 | ~ 1 | - 1
992
+ #
993
+ # [Repeat for fr.yml...]
994
+ #
995
+ # ============================================================
996
+ # DRY RUN SUMMARY
997
+ # ============================================================
998
+ # config/locales/it.yml:
999
+ # + 2 added
1000
+ # ~ 1 modified
1001
+ # - 1 removed
1002
+ #
1003
+ # config/locales/fr.yml:
1004
+ # + 3 added
1005
+ # ~ 0 modified
1006
+ # - 0 removed
1007
+ #
1008
+ # ------------------------------------------------------------
1009
+ # Total: +5 | ~1 | -1
1010
+ # ============================================================
1011
+ # ℹ No files were modified (dry run mode)
1012
+ ```
1013
+
1014
+ ### Example 2: CLI with Dry Run
1015
+
1016
+ ```bash
1017
+ # Using the standalone CLI
1018
+ better_translate translate config/locales/en.yml --to it,fr --dry-run
1019
+
1020
+ # Shows colored diff preview without writing files
1021
+ ```
1022
+
1023
+ ### Example 3: Programmatic Dry Run Check
1024
+
1025
+ ```ruby
1026
+ config = BetterTranslate::Configuration.new
1027
+ config.dry_run = true
1028
+ config.input_file = "en.yml"
1029
+ config.target_languages = [{ short_name: "it", name: "Italian" }]
1030
+
1031
+ translator = BetterTranslate::Translator.new(config)
1032
+ results = translator.translate_all
1033
+
1034
+ # In dry run mode, no files are written
1035
+ # Results include success/failure counts but no actual file modifications
1036
+ ```
1037
+
1038
+ ---
1039
+
1040
+ ## Benefits
1041
+
1042
+ 1. **Safety**: Preview changes before committing translations
1043
+ 2. **Clarity**: Color-coded diff shows exactly what will change
1044
+ 3. **Non-Destructive**: No files modified in dry-run mode
1045
+ 4. **Summary**: Clear overview of all changes across all language files
1046
+ 5. **CI/CD Integration**: Useful for validation in automated pipelines
1047
+
1048
+ ---
1049
+
1050
+ ## Implementation Checklist
1051
+
1052
+ - [ ] Create `lib/better_translate/diff_preview.rb`
1053
+ - [ ] Update `YAMLHandler#write_yaml` to accept `diff_preview` parameter
1054
+ - [ ] Update `Translator` to instantiate and use `DiffPreview`
1055
+ - [ ] Add `dry_run` configuration option
1056
+ - [ ] Create comprehensive test suite in `spec/better_translate/diff_preview_spec.rb`
1057
+ - [ ] Update CLI to support `--dry-run` flag (Phase 12)
1058
+ - [ ] Add YARD documentation for all methods
1059
+ - [ ] Update README with dry-run examples
1060
+
1061
+ ---
1062
+
1063
+ ---
1064
+
1065
+ [← Previous: 04-Provider Architecture](./04-provider_architecture.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 06-Main Module Api →](./06-main_module_api.md)