better_translate 1.0.0.1 → 1.1.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +28 -0
  3. data/.rubocop_todo.yml +291 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +12 -7
  6. data/CONTRIBUTING.md +432 -0
  7. data/README.md +240 -1
  8. data/Rakefile +14 -1
  9. data/SECURITY.md +160 -0
  10. data/Steepfile +0 -1
  11. data/brakeman.yml +37 -0
  12. data/codecov.yml +34 -0
  13. data/lib/better_translate/analyzer/code_scanner.rb +149 -0
  14. data/lib/better_translate/analyzer/key_scanner.rb +109 -0
  15. data/lib/better_translate/analyzer/orphan_detector.rb +91 -0
  16. data/lib/better_translate/analyzer/reporter.rb +155 -0
  17. data/lib/better_translate/cli.rb +81 -2
  18. data/lib/better_translate/configuration.rb +76 -3
  19. data/lib/better_translate/errors.rb +9 -0
  20. data/lib/better_translate/json_handler.rb +227 -0
  21. data/lib/better_translate/translator.rb +205 -23
  22. data/lib/better_translate/version.rb +1 -1
  23. data/lib/better_translate/yaml_handler.rb +59 -0
  24. data/lib/better_translate.rb +7 -0
  25. data/lib/generators/better_translate/install/install_generator.rb +2 -2
  26. data/lib/generators/better_translate/install/templates/initializer.rb.tt +22 -34
  27. data/lib/generators/better_translate/translate/translate_generator.rb +65 -46
  28. data/lib/tasks/better_translate.rake +62 -45
  29. data/sig/better_translate/analyzer/code_scanner.rbs +59 -0
  30. data/sig/better_translate/analyzer/key_scanner.rbs +40 -0
  31. data/sig/better_translate/analyzer/orphan_detector.rbs +43 -0
  32. data/sig/better_translate/analyzer/reporter.rbs +70 -0
  33. data/sig/better_translate/cli.rbs +2 -0
  34. data/sig/better_translate/configuration.rbs +6 -0
  35. data/sig/better_translate/errors.rbs +4 -0
  36. data/sig/better_translate/json_handler.rbs +65 -0
  37. data/sig/better_translate/progress_tracker.rbs +1 -1
  38. data/sig/better_translate/translator.rbs +12 -1
  39. data/sig/better_translate/yaml_handler.rbs +6 -0
  40. data/sig/better_translate.rbs +4 -0
  41. data/sig/csv.rbs +16 -0
  42. metadata +32 -3
  43. data/regenerate_vcr.rb +0 -47
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "csv"
5
+
6
+ module BetterTranslate
7
+ module Analyzer
8
+ # Generates reports for orphan key analysis
9
+ #
10
+ # Supports multiple output formats: text, JSON, CSV
11
+ #
12
+ # @example Basic usage
13
+ # reporter = Reporter.new(
14
+ # orphans: ["orphan_key"],
15
+ # orphan_details: { "orphan_key" => "Unused" },
16
+ # total_keys: 10,
17
+ # used_keys: 9,
18
+ # usage_percentage: 90.0,
19
+ # format: :text
20
+ # )
21
+ # puts reporter.generate
22
+ #
23
+ class Reporter
24
+ # @return [Array<String>] List of orphan keys
25
+ attr_reader :orphans
26
+
27
+ # @return [Hash] Orphan keys with their values
28
+ attr_reader :orphan_details
29
+
30
+ # @return [Integer] Total number of keys
31
+ attr_reader :total_keys
32
+
33
+ # @return [Integer] Number of used keys
34
+ attr_reader :used_keys
35
+
36
+ # @return [Float] Usage percentage
37
+ attr_reader :usage_percentage
38
+
39
+ # @return [Symbol] Output format (:text, :json, :csv)
40
+ attr_reader :format
41
+
42
+ # Initialize reporter
43
+ #
44
+ # @param orphans [Array<String>] List of orphan keys
45
+ # @param orphan_details [Hash] Orphan keys with values
46
+ # @param total_keys [Integer] Total number of keys
47
+ # @param used_keys [Integer] Number of used keys
48
+ # @param usage_percentage [Float] Usage percentage
49
+ # @param format [Symbol] Output format (:text, :json, :csv)
50
+ #
51
+ def initialize(orphans:, orphan_details:, total_keys:, used_keys:, usage_percentage:, format: :text)
52
+ @orphans = orphans
53
+ @orphan_details = orphan_details
54
+ @total_keys = total_keys
55
+ @used_keys = used_keys
56
+ @usage_percentage = usage_percentage
57
+ @format = format
58
+ end
59
+
60
+ # Generate report in specified format
61
+ #
62
+ # @return [String] Generated report
63
+ #
64
+ def generate
65
+ case format
66
+ when :json
67
+ generate_json
68
+ when :csv
69
+ generate_csv
70
+ else
71
+ generate_text
72
+ end
73
+ end
74
+
75
+ # Save report to file
76
+ #
77
+ # @param file_path [String] Output file path
78
+ #
79
+ def save_to_file(file_path)
80
+ File.write(file_path, generate)
81
+ end
82
+
83
+ private
84
+
85
+ # Generate text format report
86
+ #
87
+ # @return [String] Text report
88
+ #
89
+ def generate_text
90
+ lines = [] # : Array[String]
91
+ lines << "=" * 60
92
+ lines << "Orphan Keys Analysis Report"
93
+ lines << "=" * 60
94
+ lines << ""
95
+ lines << "Statistics:"
96
+ lines << " Total keys: #{total_keys}"
97
+ lines << " Used keys: #{used_keys}"
98
+ lines << " Orphan keys: #{orphans.size}"
99
+ lines << " Usage: #{usage_percentage}%"
100
+ lines << ""
101
+
102
+ if orphans.empty?
103
+ lines << "✓ No orphan keys found! All translation keys are being used."
104
+ else
105
+ lines << "Orphan Keys (#{orphans.size}):"
106
+ lines << "-" * 60
107
+
108
+ orphans.each do |key|
109
+ value = orphan_details[key]
110
+ lines << ""
111
+ lines << " Key: #{key}"
112
+ lines << " Value: #{value}" if value
113
+ end
114
+ end
115
+
116
+ lines << ""
117
+ lines << "=" * 60
118
+
119
+ lines.join("\n")
120
+ end
121
+
122
+ # Generate JSON format report
123
+ #
124
+ # @return [String] JSON report
125
+ #
126
+ def generate_json
127
+ data = {
128
+ orphans: orphans,
129
+ orphan_details: orphan_details,
130
+ orphan_count: orphans.size,
131
+ total_keys: total_keys,
132
+ used_keys: used_keys,
133
+ usage_percentage: usage_percentage
134
+ }
135
+
136
+ JSON.pretty_generate(data)
137
+ end
138
+
139
+ # Generate CSV format report
140
+ #
141
+ # @return [String] CSV report
142
+ #
143
+ def generate_csv
144
+ CSV.generate do |csv|
145
+ csv << %w[Key Value]
146
+
147
+ orphans.each do |key|
148
+ value = orphan_details[key]
149
+ csv << [key, value]
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -53,6 +53,8 @@ module BetterTranslate
53
53
  run_generate
54
54
  when "direct"
55
55
  run_direct
56
+ when "analyze"
57
+ run_analyze
56
58
  when "--version", "-v"
57
59
  puts "BetterTranslate version #{VERSION}"
58
60
  when "--help", "-h", nil
@@ -79,6 +81,7 @@ module BetterTranslate
79
81
  translate Translate YAML files using config file
80
82
  generate OUTPUT_FILE Generate sample config file
81
83
  direct TEXT Translate text directly
84
+ analyze Analyze YAML files for orphan keys
82
85
 
83
86
  Options:
84
87
  --help, -h Show this help message
@@ -88,6 +91,7 @@ module BetterTranslate
88
91
  better_translate translate --config config.yml
89
92
  better_translate generate config.yml
90
93
  better_translate direct "Hello" --to it --provider chatgpt --api-key KEY
94
+ better_translate analyze --source config/locales/en.yml --scan-path app/
91
95
  HELP
92
96
  end
93
97
 
@@ -221,8 +225,8 @@ module BetterTranslate
221
225
  "dry_run" => false,
222
226
  "translation_mode" => "override",
223
227
  "preserve_variables" => true,
224
- "global_exclusions" => [],
225
- "exclusions_per_language" => {},
228
+ "global_exclusions" => [], # : Array[String]
229
+ "exclusions_per_language" => {}, # : Hash[String, Array[String]]
226
230
  "model" => nil,
227
231
  "temperature" => 0.3,
228
232
  "max_tokens" => 2000,
@@ -300,5 +304,80 @@ module BetterTranslate
300
304
  rescue StandardError => e
301
305
  puts "Error: #{e.message}"
302
306
  end
307
+
308
+ # Run analyze command
309
+ #
310
+ # @return [void]
311
+ # @api private
312
+ #
313
+ def run_analyze
314
+ # @type var options: Hash[Symbol, String]
315
+ options = {}
316
+ OptionParser.new do |opts|
317
+ opts.on("--source FILE", "Source YAML file path") { |v| options[:source] = v }
318
+ opts.on("--scan-path PATH", "Path to scan for code files") { |v| options[:scan_path] = v }
319
+ opts.on("--format FORMAT", "Output format (text, json, csv)") { |v| options[:format] = v }
320
+ opts.on("--output FILE", "Output file path") { |v| options[:output] = v }
321
+ end.parse!(args[1..])
322
+
323
+ # Validate required options
324
+ unless options[:source]
325
+ puts "Error: --source is required"
326
+ return
327
+ end
328
+
329
+ unless options[:scan_path]
330
+ puts "Error: --scan-path is required"
331
+ return
332
+ end
333
+
334
+ # Validate paths exist
335
+ unless File.exist?(options[:source])
336
+ puts "Error: Source file not found: #{options[:source]}"
337
+ return
338
+ end
339
+
340
+ unless File.exist?(options[:scan_path])
341
+ puts "Error: Scan path not found: #{options[:scan_path]}"
342
+ return
343
+ end
344
+
345
+ # Default format
346
+ format = (options[:format] || "text").to_sym
347
+
348
+ # Scan keys from YAML
349
+ key_scanner = Analyzer::KeyScanner.new(options[:source])
350
+ all_keys = key_scanner.scan
351
+
352
+ # Scan code for used keys
353
+ code_scanner = Analyzer::CodeScanner.new(options[:scan_path])
354
+ used_keys = code_scanner.scan
355
+
356
+ # Detect orphans
357
+ detector = Analyzer::OrphanDetector.new(all_keys, used_keys)
358
+ orphans = detector.detect
359
+
360
+ # Generate report
361
+ reporter = Analyzer::Reporter.new(
362
+ orphans: orphans,
363
+ orphan_details: detector.orphan_details,
364
+ total_keys: all_keys.size,
365
+ used_keys: used_keys.size,
366
+ usage_percentage: detector.usage_percentage,
367
+ format: format
368
+ )
369
+
370
+ report = reporter.generate
371
+
372
+ # Output or save
373
+ if options[:output]
374
+ reporter.save_to_file(options[:output])
375
+ puts "Report saved to #{options[:output]}"
376
+ else
377
+ puts report
378
+ end
379
+ rescue StandardError => e
380
+ puts "Error: #{e.message}"
381
+ end
303
382
  end
304
383
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+
3
6
  module BetterTranslate
4
7
  # Configuration class for BetterTranslate
5
8
  #
@@ -13,6 +16,15 @@ module BetterTranslate
13
16
  # config.target_languages = [{ short_name: "it", name: "Italian" }]
14
17
  # config.validate!
15
18
  #
19
+ # @example Provider-specific options
20
+ # config.model = "gpt-5-nano" # Specify model (optional, provider-specific)
21
+ # config.temperature = 0.7 # Control creativity (0.0-2.0, default: 0.3)
22
+ # config.max_tokens = 1500 # Limit response length (default: 2000)
23
+ #
24
+ # @example Backup configuration
25
+ # config.create_backup = true # Enable automatic backups (default: true)
26
+ # config.max_backups = 5 # Keep up to 5 backup files (default: 3)
27
+ #
16
28
  class Configuration
17
29
  # @return [Symbol] The translation provider (:chatgpt, :gemini, :anthropic)
18
30
  attr_accessor :provider
@@ -40,9 +52,12 @@ module BetterTranslate
40
52
  # @return [Array<Hash>] Target languages with :short_name and :name
41
53
  attr_accessor :target_languages
42
54
 
43
- # @return [String] Path to input YAML file
55
+ # @return [String] Path to input YAML file (for backward compatibility, use input_files for multiple files)
44
56
  attr_accessor :input_file
45
57
 
58
+ # @return [Array<String>, String] Multiple input files (array or glob pattern)
59
+ attr_accessor :input_files
60
+
46
61
  # @return [String] Output folder for translated files
47
62
  attr_accessor :output_folder
48
63
 
@@ -88,6 +103,21 @@ module BetterTranslate
88
103
  # @return [Boolean] Preserve interpolation variables during translation (default: true)
89
104
  attr_accessor :preserve_variables
90
105
 
106
+ # @return [String, nil] AI model to use (provider-specific, e.g., "gpt-5-nano", "gemini-2.0-flash-exp")
107
+ attr_accessor :model
108
+
109
+ # @return [Float] Temperature for AI generation (0.0-2.0, higher = more creative)
110
+ attr_accessor :temperature
111
+
112
+ # @return [Integer] Maximum tokens for AI response
113
+ attr_accessor :max_tokens
114
+
115
+ # @return [Boolean] Create backup files before overwriting (default: true)
116
+ attr_accessor :create_backup
117
+
118
+ # @return [Integer] Maximum number of backup files to keep (default: 3)
119
+ attr_accessor :max_backups
120
+
91
121
  # Initialize a new configuration with defaults
92
122
  def initialize
93
123
  @translation_mode = :override
@@ -104,6 +134,11 @@ module BetterTranslate
104
134
  @exclusions_per_language = {}
105
135
  @target_languages = []
106
136
  @preserve_variables = true
137
+ @model = nil
138
+ @temperature = 0.3
139
+ @max_tokens = 2000
140
+ @create_backup = true
141
+ @max_backups = 3
107
142
  end
108
143
 
109
144
  # Validate the configuration
@@ -176,9 +211,20 @@ module BetterTranslate
176
211
  # @return [void]
177
212
  # @api private
178
213
  def validate_files!
179
- raise ConfigurationError, "Input file must be set" if input_file.nil? || input_file.empty?
214
+ # Check if either input_file or input_files is set
215
+ has_input = (input_file && !input_file.empty?) || input_files
216
+
217
+ raise ConfigurationError, "Input file or input_files must be set" unless has_input
180
218
  raise ConfigurationError, "Output folder must be set" if output_folder.nil? || output_folder.empty?
181
- raise ConfigurationError, "Input file does not exist: #{input_file}" unless File.exist?(input_file)
219
+
220
+ # Only validate input_file exists if using single file mode (not glob pattern or array)
221
+ return unless input_file && !input_file.empty? && !input_files
222
+
223
+ # Create input file if it doesn't exist
224
+ return if File.exist?(input_file)
225
+
226
+ create_default_input_file!(input_file)
227
+ puts "Created empty input file: #{input_file}" if verbose
182
228
  end
183
229
 
184
230
  # Validate optional settings (timeouts, retries, cache, etc.)
@@ -196,6 +242,33 @@ module BetterTranslate
196
242
  raise ConfigurationError, "Request timeout must be positive" if request_timeout <= 0
197
243
  raise ConfigurationError, "Max retries must be non-negative" if max_retries.negative?
198
244
  raise ConfigurationError, "Cache size must be positive" if cache_size <= 0
245
+
246
+ # Validate temperature range (AI providers typically accept 0.0-2.0)
247
+ if temperature && (temperature < 0.0 || temperature > 2.0)
248
+ raise ConfigurationError, "Temperature must be between 0.0 and 2.0"
249
+ end
250
+
251
+ # Validate max_tokens is positive
252
+ raise ConfigurationError, "Max tokens must be positive" if max_tokens && max_tokens <= 0
253
+ end
254
+
255
+ # Create a default input file with root language key
256
+ #
257
+ # @param file_path [String] Path to the input file
258
+ # @return [void]
259
+ # @api private
260
+ def create_default_input_file!(file_path)
261
+ # Create directory if needed
262
+ FileUtils.mkdir_p(File.dirname(file_path))
263
+
264
+ # Determine file format (YAML or JSON)
265
+ content = if file_path.end_with?(".json")
266
+ JSON.pretty_generate({ source_language => {} })
267
+ else
268
+ { source_language => {} }.to_yaml
269
+ end
270
+
271
+ File.write(file_path, content)
199
272
  end
200
273
  end
201
274
  end
@@ -90,6 +90,15 @@ module BetterTranslate
90
90
  # )
91
91
  class YamlError < Error; end
92
92
 
93
+ # Raised when JSON parsing fails
94
+ #
95
+ # @example JSON syntax error
96
+ # raise JsonError.new(
97
+ # "Invalid JSON syntax",
98
+ # context: { file_path: "config/locales/en.json", error: "unexpected token" }
99
+ # )
100
+ class JsonError < Error; end
101
+
93
102
  # Raised when a provider is not found
94
103
  #
95
104
  # @example Provider not found
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module BetterTranslate
7
+ # Handles JSON file operations
8
+ #
9
+ # Provides methods for:
10
+ # - Reading and parsing JSON files
11
+ # - Writing JSON files with proper formatting
12
+ # - Merging translations (incremental mode)
13
+ # - Handling exclusions
14
+ # - Flattening/unflattening nested structures
15
+ #
16
+ # @example Reading a JSON file
17
+ # handler = JsonHandler.new(config)
18
+ # data = handler.read_json("config/locales/en.json")
19
+ #
20
+ # @example Writing translations
21
+ # handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
22
+ #
23
+ class JsonHandler
24
+ # @return [Configuration] Configuration object
25
+ attr_reader :config
26
+
27
+ # Initialize JSON handler
28
+ #
29
+ # @param config [Configuration] Configuration object
30
+ #
31
+ # @example
32
+ # config = Configuration.new
33
+ # handler = JsonHandler.new(config)
34
+ #
35
+ def initialize(config)
36
+ @config = config
37
+ end
38
+
39
+ # Read and parse JSON file
40
+ #
41
+ # @param file_path [String] Path to JSON file
42
+ # @return [Hash] Parsed JSON content
43
+ # @raise [FileError] if file cannot be read
44
+ # @raise [JsonError] if JSON is invalid
45
+ #
46
+ # @example
47
+ # data = handler.read_json("config/locales/en.json")
48
+ # #=> { "en" => { "greeting" => "Hello" } }
49
+ #
50
+ def read_json(file_path)
51
+ Validator.validate_file_exists!(file_path)
52
+
53
+ content = File.read(file_path)
54
+ return {} if content.strip.empty?
55
+
56
+ JSON.parse(content)
57
+ rescue Errno::ENOENT => e
58
+ raise FileError.new("File does not exist: #{file_path}", context: { error: e.message })
59
+ rescue JSON::ParserError => e
60
+ raise JsonError.new("Invalid JSON syntax in #{file_path}", context: { error: e.message })
61
+ end
62
+
63
+ # Write hash to JSON file
64
+ #
65
+ # @param file_path [String] Output file path
66
+ # @param data [Hash] Data to write
67
+ # @param diff_preview [DiffPreview, nil] Optional diff preview instance
68
+ # @return [Hash, nil] Summary hash if dry_run, nil otherwise
69
+ # @raise [FileError] if file cannot be written
70
+ #
71
+ # @example
72
+ # handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
73
+ #
74
+ def write_json(file_path, data, diff_preview: nil)
75
+ summary = nil
76
+
77
+ # Show diff preview if in dry run mode
78
+ if config.dry_run && diff_preview
79
+ existing_data = File.exist?(file_path) ? read_json(file_path) : {} # : Hash[untyped, untyped]
80
+ summary = diff_preview.show_diff(existing_data, data, file_path)
81
+ end
82
+
83
+ return summary if config.dry_run
84
+
85
+ # Create backup if enabled and file exists
86
+ create_backup_file(file_path) if config.create_backup && File.exist?(file_path)
87
+
88
+ # Ensure output directory exists
89
+ FileUtils.mkdir_p(File.dirname(file_path))
90
+
91
+ # Write JSON with proper indentation
92
+ File.write(file_path, JSON.pretty_generate(data))
93
+
94
+ nil
95
+ rescue Errno::EACCES => e
96
+ raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
97
+ rescue StandardError => e
98
+ raise FileError.new("Failed to write JSON: #{file_path}", context: { error: e.message })
99
+ end
100
+
101
+ # Get translatable strings from source JSON
102
+ #
103
+ # Reads the input file and returns a flattened hash of strings.
104
+ # Removes the root language key if present.
105
+ #
106
+ # @return [Hash] Flattened hash of translatable strings
107
+ #
108
+ # @example
109
+ # strings = handler.get_source_strings
110
+ # #=> { "greeting" => "Hello", "nav.home" => "Home" }
111
+ #
112
+ def get_source_strings
113
+ return {} unless config.input_file
114
+
115
+ source_data = read_json(config.input_file)
116
+ # Remove root language key if present (e.g., "en:")
117
+ source_data = source_data[config.source_language] || source_data
118
+
119
+ Utils::HashFlattener.flatten(source_data)
120
+ end
121
+
122
+ # Filter out excluded keys for a specific language
123
+ #
124
+ # @param strings [Hash] Flattened strings
125
+ # @param target_lang_code [String] Target language code
126
+ # @return [Hash] Filtered strings
127
+ #
128
+ # @example
129
+ # filtered = handler.filter_exclusions(strings, "it")
130
+ #
131
+ def filter_exclusions(strings, target_lang_code)
132
+ excluded_keys = config.global_exclusions.dup
133
+ excluded_keys += config.exclusions_per_language[target_lang_code] || []
134
+
135
+ strings.reject { |key, _| excluded_keys.include?(key) }
136
+ end
137
+
138
+ # Merge translated strings with existing file (incremental mode)
139
+ #
140
+ # @param file_path [String] Existing file path
141
+ # @param new_translations [Hash] New translations (flattened)
142
+ # @return [Hash] Merged translations (nested)
143
+ #
144
+ # @example
145
+ # merged = handler.merge_translations("config/locales/it.json", new_translations)
146
+ #
147
+ def merge_translations(file_path, new_translations)
148
+ if File.exist?(file_path)
149
+ existing = read_json(file_path)
150
+ # Extract actual translations (remove language wrapper if present)
151
+ target_lang = config.target_languages.first[:short_name]
152
+ existing = existing[target_lang] || existing
153
+ else
154
+ existing = {} # : Hash[untyped, untyped]
155
+ end
156
+
157
+ existing_flat = Utils::HashFlattener.flatten(existing)
158
+
159
+ # Merge: existing takes precedence
160
+ merged = new_translations.merge(existing_flat)
161
+
162
+ Utils::HashFlattener.unflatten(merged)
163
+ end
164
+
165
+ # Build output file path for target language
166
+ #
167
+ # @param target_lang_code [String] Target language code
168
+ # @return [String] Output file path
169
+ #
170
+ # @example
171
+ # path = handler.build_output_path("it")
172
+ # #=> "config/locales/it.json"
173
+ #
174
+ def build_output_path(target_lang_code)
175
+ return "#{target_lang_code}.json" unless config.output_folder
176
+
177
+ File.join(config.output_folder, "#{target_lang_code}.json")
178
+ end
179
+
180
+ private
181
+
182
+ # Create backup file with rotation support
183
+ #
184
+ # @param file_path [String] Path to file to backup
185
+ # @return [void]
186
+ # @api private
187
+ #
188
+ def create_backup_file(file_path)
189
+ return unless File.exist?(file_path)
190
+
191
+ # Rotate existing backups if max_backups > 1
192
+ rotate_backups(file_path) if config.max_backups > 1
193
+
194
+ # Create primary backup
195
+ backup_path = "#{file_path}.bak"
196
+ FileUtils.cp(file_path, backup_path)
197
+ end
198
+
199
+ # Rotate backup files, keeping only max_backups
200
+ #
201
+ # @param file_path [String] Base file path
202
+ # @return [void]
203
+ # @api private
204
+ #
205
+ def rotate_backups(file_path)
206
+ primary_backup = "#{file_path}.bak"
207
+ return unless File.exist?(primary_backup)
208
+
209
+ # Clean up ANY backups that would exceed max_backups after rotation
210
+ 10.downto(config.max_backups) do |i|
211
+ numbered_backup = "#{file_path}.bak.#{i}"
212
+ FileUtils.rm_f(numbered_backup) if File.exist?(numbered_backup)
213
+ end
214
+
215
+ # Rotate numbered backups from high to low to avoid overwrites
216
+ (config.max_backups - 2).downto(1) do |i|
217
+ old_path = "#{file_path}.bak.#{i}"
218
+ new_path = "#{file_path}.bak.#{i + 1}"
219
+
220
+ FileUtils.mv(old_path, new_path) if File.exist?(old_path)
221
+ end
222
+
223
+ # Move primary backup to .bak.1
224
+ FileUtils.mv(primary_backup, "#{file_path}.bak.1")
225
+ end
226
+ end
227
+ end