cabriolet 0.1.2 → 0.2.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +700 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +154 -14
  6. data/lib/cabriolet/binary/bitstream_writer.rb +129 -17
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +35 -43
  13. data/lib/cabriolet/cab/decompressor.rb +14 -19
  14. data/lib/cabriolet/cab/extractor.rb +140 -31
  15. data/lib/cabriolet/chm/command_handler.rb +227 -0
  16. data/lib/cabriolet/chm/compressor.rb +7 -3
  17. data/lib/cabriolet/chm/decompressor.rb +39 -21
  18. data/lib/cabriolet/chm/parser.rb +5 -2
  19. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  20. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  21. data/lib/cabriolet/cli/command_registry.rb +83 -0
  22. data/lib/cabriolet/cli.rb +356 -607
  23. data/lib/cabriolet/compressors/base.rb +1 -1
  24. data/lib/cabriolet/compressors/lzx.rb +241 -54
  25. data/lib/cabriolet/compressors/mszip.rb +35 -3
  26. data/lib/cabriolet/compressors/quantum.rb +34 -45
  27. data/lib/cabriolet/decompressors/base.rb +1 -1
  28. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  29. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  30. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  31. data/lib/cabriolet/decompressors/quantum.rb +3 -2
  32. data/lib/cabriolet/errors.rb +3 -0
  33. data/lib/cabriolet/file_entry.rb +156 -0
  34. data/lib/cabriolet/file_manager.rb +144 -0
  35. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  36. data/lib/cabriolet/hlp/compressor.rb +28 -238
  37. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  38. data/lib/cabriolet/hlp/parser.rb +52 -101
  39. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  40. data/lib/cabriolet/hlp/quickhelp/compressor.rb +626 -0
  41. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  42. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  43. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  44. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  45. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  46. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  47. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  48. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  49. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  50. data/lib/cabriolet/huffman/tree.rb +85 -1
  51. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  52. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  53. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  54. data/lib/cabriolet/lit/command_handler.rb +221 -0
  55. data/lib/cabriolet/lit/compressor.rb +633 -38
  56. data/lib/cabriolet/lit/decompressor.rb +518 -152
  57. data/lib/cabriolet/lit/parser.rb +670 -0
  58. data/lib/cabriolet/models/hlp_file.rb +130 -29
  59. data/lib/cabriolet/models/hlp_header.rb +105 -17
  60. data/lib/cabriolet/models/lit_header.rb +212 -25
  61. data/lib/cabriolet/models/szdd_header.rb +10 -2
  62. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  63. data/lib/cabriolet/oab/command_handler.rb +257 -0
  64. data/lib/cabriolet/oab/compressor.rb +17 -8
  65. data/lib/cabriolet/oab/decompressor.rb +41 -10
  66. data/lib/cabriolet/offset_calculator.rb +81 -0
  67. data/lib/cabriolet/plugin.rb +233 -0
  68. data/lib/cabriolet/plugin_manager.rb +453 -0
  69. data/lib/cabriolet/plugin_validator.rb +422 -0
  70. data/lib/cabriolet/system/io_system.rb +3 -0
  71. data/lib/cabriolet/system/memory_handle.rb +17 -4
  72. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  73. data/lib/cabriolet/szdd/compressor.rb +15 -11
  74. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  75. data/lib/cabriolet/version.rb +1 -1
  76. data/lib/cabriolet.rb +67 -17
  77. metadata +33 -2
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ # Validates plugin classes and configurations
5
+ #
6
+ # The PluginValidator provides comprehensive validation for plugins
7
+ # including inheritance checks, metadata validation, version
8
+ # compatibility, and safety scanning.
9
+ #
10
+ # @example Validate a plugin class
11
+ # result = PluginValidator.validate(MyPlugin)
12
+ # if result[:valid]
13
+ # puts "Plugin is valid"
14
+ # else
15
+ # puts "Errors: #{result[:errors].join(', ')}"
16
+ # end
17
+ class PluginValidator
18
+ # Required metadata fields
19
+ REQUIRED_METADATA = %i[name version author description
20
+ cabriolet_version].freeze
21
+
22
+ # Dangerous method names to check for
23
+ DANGEROUS_METHODS = %w[
24
+ system exec spawn ` fork eval instance_eval class_eval
25
+ module_eval binding const_set remove_const send __send__
26
+ method_missing respond_to_missing?
27
+ ].freeze
28
+
29
+ class << self
30
+ # Validate a plugin class
31
+ #
32
+ # Performs comprehensive validation including inheritance, metadata,
33
+ # version compatibility, and safety checks.
34
+ #
35
+ # @param plugin_class [Class] Plugin class to validate
36
+ #
37
+ # @return [Hash] Validation result with:
38
+ # - :valid [Boolean] True if all checks pass
39
+ # - :errors [Array<String>] List of validation errors (empty if
40
+ # valid)
41
+ # - :warnings [Array<String>] List of warnings (non-fatal issues)
42
+ #
43
+ # @example Validate a plugin
44
+ # result = PluginValidator.validate(MyPlugin)
45
+ # result[:valid] #=> true
46
+ # result[:errors] #=> []
47
+ # result[:warnings] #=> ["Uses eval in setup method"]
48
+ def validate(plugin_class)
49
+ errors = []
50
+ warnings = []
51
+
52
+ # Check inheritance
53
+ inherit_errors = validate_inheritance(plugin_class)
54
+ errors.concat(inherit_errors)
55
+
56
+ # If inheritance fails, stop here
57
+ return { valid: false, errors: errors, warnings: warnings } unless
58
+ inherit_errors.empty?
59
+
60
+ # Create instance to check metadata
61
+ begin
62
+ instance = plugin_class.new(nil)
63
+ metadata = instance.metadata
64
+
65
+ # Validate metadata
66
+ meta_errors = validate_metadata(metadata)
67
+ errors.concat(meta_errors)
68
+
69
+ # Check version compatibility
70
+ if metadata[:cabriolet_version]
71
+ version_errors = validate_version_compatibility(
72
+ metadata[:cabriolet_version],
73
+ Cabriolet::VERSION,
74
+ )
75
+ errors.concat(version_errors)
76
+ end
77
+
78
+ # Validate dependencies
79
+ if metadata[:dependencies]
80
+ dep_warnings = validate_dependencies(metadata[:dependencies])
81
+ warnings.concat(dep_warnings)
82
+ end
83
+ rescue NotImplementedError => e
84
+ errors << "Plugin does not implement required method: " \
85
+ "#{e.message}"
86
+ rescue StandardError => e
87
+ errors << "Failed to instantiate plugin: #{e.message}"
88
+ end
89
+
90
+ # Safety checks
91
+ safety_warnings = check_safety(plugin_class)
92
+ warnings.concat(safety_warnings)
93
+
94
+ {
95
+ valid: errors.empty?,
96
+ errors: errors,
97
+ warnings: warnings,
98
+ }
99
+ end
100
+
101
+ # Validate plugin inheritance
102
+ #
103
+ # Checks that the plugin class properly inherits from
104
+ # Cabriolet::Plugin.
105
+ #
106
+ # @param plugin_class [Class] Plugin class to validate
107
+ #
108
+ # @return [Array<String>] List of inheritance errors (empty if valid)
109
+ #
110
+ # @example Valid inheritance
111
+ # PluginValidator.validate_inheritance(MyPlugin)
112
+ # #=> []
113
+ #
114
+ # @example Invalid inheritance
115
+ # PluginValidator.validate_inheritance(Object)
116
+ # #=> ["Plugin must inherit from Cabriolet::Plugin"]
117
+ def validate_inheritance(plugin_class)
118
+ errors = []
119
+
120
+ unless plugin_class.is_a?(Class)
121
+ errors << "Plugin must be a class, got #{plugin_class.class}"
122
+ return errors
123
+ end
124
+
125
+ unless plugin_class < Plugin
126
+ errors << "Plugin must inherit from Cabriolet::Plugin"
127
+ end
128
+
129
+ errors
130
+ end
131
+
132
+ # Validate plugin metadata
133
+ #
134
+ # Checks that all required metadata fields are present and valid.
135
+ #
136
+ # @param metadata [Hash] Plugin metadata to validate
137
+ #
138
+ # @return [Array<String>] List of metadata errors (empty if valid)
139
+ #
140
+ # @example Valid metadata
141
+ # meta = { name: "test", version: "1.0", ... }
142
+ # PluginValidator.validate_metadata(meta)
143
+ # #=> []
144
+ #
145
+ # @example Missing fields
146
+ # PluginValidator.validate_metadata({})
147
+ # #=> ["Missing required metadata: name, version, ..."]
148
+ def validate_metadata(metadata)
149
+ errors = []
150
+
151
+ unless metadata.is_a?(Hash)
152
+ errors << "Metadata must be a Hash"
153
+ return errors
154
+ end
155
+
156
+ # Check required fields
157
+ missing = REQUIRED_METADATA - metadata.keys
158
+ unless missing.empty?
159
+ errors << "Missing required metadata: #{missing.join(', ')}"
160
+ end
161
+
162
+ # Validate field types and formats
163
+ if metadata[:name]
164
+ unless metadata[:name].is_a?(String) &&
165
+ !metadata[:name].empty?
166
+ errors << "Plugin name must be a non-empty string"
167
+ end
168
+
169
+ if metadata[:name].is_a?(String) && metadata[:name] =~ /^[a-z0-9_-]+$/
170
+ # Valid format - do nothing
171
+ elsif metadata[:name].is_a?(String)
172
+ errors << "Plugin name must contain only lowercase letters, " \
173
+ "numbers, hyphens, and underscores"
174
+ end
175
+ end
176
+
177
+ if metadata[:version] && !valid_version?(metadata[:version])
178
+ errors << "Plugin version must be a valid semantic version " \
179
+ "(e.g., '1.0.0')"
180
+ end
181
+
182
+ if metadata[:author] && !(metadata[:author].is_a?(String) &&
183
+ !metadata[:author].empty?)
184
+ errors << "Plugin author must be a non-empty string"
185
+ end
186
+
187
+ if metadata[:description] && !(metadata[:description].is_a?(String) &&
188
+ !metadata[:description].empty?)
189
+ errors << "Plugin description must be a non-empty string"
190
+ end
191
+
192
+ # Optional fields validation
193
+ if metadata[:homepage] && !metadata[:homepage].empty? && !valid_url?(metadata[:homepage])
194
+ errors << "Plugin homepage must be a valid URL"
195
+ end
196
+
197
+ if metadata[:dependencies] && !metadata[:dependencies].is_a?(Array)
198
+ errors << "Plugin dependencies must be an array"
199
+ end
200
+
201
+ if metadata[:tags] && !metadata[:tags].is_a?(Array)
202
+ errors << "Plugin tags must be an array"
203
+ end
204
+
205
+ errors
206
+ end
207
+
208
+ # Validate version compatibility
209
+ #
210
+ # Checks if the plugin's required Cabriolet version matches the
211
+ # current version.
212
+ #
213
+ # @param plugin_version [String] Required Cabriolet version
214
+ # @param cabriolet_version [String] Current Cabriolet version
215
+ #
216
+ # @return [Array<String>] List of version errors (empty if
217
+ # compatible)
218
+ #
219
+ # @example Compatible version
220
+ # PluginValidator.validate_version_compatibility("~> 0.1", "0.1.0")
221
+ # #=> []
222
+ #
223
+ # @example Incompatible version
224
+ # PluginValidator.validate_version_compatibility(">= 2.0", "0.1.0")
225
+ # #=> ["Plugin requires Cabriolet version >= 2.0, ..."]
226
+ def validate_version_compatibility(plugin_version, cabriolet_version)
227
+ errors = []
228
+
229
+ # Parse version requirement
230
+ if plugin_version.start_with?("~>")
231
+ # Pessimistic version constraint
232
+ required = plugin_version.sub("~>", "").strip
233
+ unless version_compatible?(cabriolet_version, required, :pessimistic)
234
+ errors << "Plugin requires Cabriolet version ~> #{required}, " \
235
+ "but #{cabriolet_version} is installed"
236
+ end
237
+ elsif plugin_version.start_with?(">=")
238
+ # Minimum version
239
+ required = plugin_version.sub(">=", "").strip
240
+ unless version_compatible?(cabriolet_version, required, :gte)
241
+ errors << "Plugin requires Cabriolet version >= #{required}, " \
242
+ "but #{cabriolet_version} is installed"
243
+ end
244
+ elsif plugin_version.start_with?("=")
245
+ # Exact version
246
+ required = plugin_version.sub("=", "").strip
247
+ unless cabriolet_version == required
248
+ errors << "Plugin requires exact Cabriolet version #{required}, " \
249
+ "but #{cabriolet_version} is installed"
250
+ end
251
+ end
252
+
253
+ errors
254
+ end
255
+
256
+ # Validate plugin dependencies
257
+ #
258
+ # Checks if dependency specifications are valid. This performs
259
+ # format validation only; actual dependency resolution happens at
260
+ # load time.
261
+ #
262
+ # @param dependencies [Array<String>] Dependency specifications
263
+ #
264
+ # @return [Array<String>] List of validation warnings
265
+ #
266
+ # @example Valid dependencies
267
+ # deps = ["other-plugin >= 1.0"]
268
+ # PluginValidator.validate_dependencies(deps)
269
+ # #=> []
270
+ def validate_dependencies(dependencies)
271
+ warnings = []
272
+
273
+ unless dependencies.is_a?(Array)
274
+ warnings << "Dependencies must be an array"
275
+ return warnings
276
+ end
277
+
278
+ dependencies.each do |dep|
279
+ unless dep.is_a?(String)
280
+ warnings << "Each dependency must be a string"
281
+ next
282
+ end
283
+
284
+ parts = dep.split
285
+ if parts.empty?
286
+ warnings << "Empty dependency specification"
287
+ elsif !/^[a-z0-9_-]+$/.match?(parts[0])
288
+ warnings << "Invalid dependency name: #{parts[0]}"
289
+ end
290
+ end
291
+
292
+ warnings
293
+ end
294
+
295
+ # Check plugin for potentially dangerous code
296
+ #
297
+ # Scans the plugin's source code for dangerous method calls that
298
+ # might pose security risks.
299
+ #
300
+ # @param plugin_class [Class] Plugin class to check
301
+ #
302
+ # @return [Array<String>] List of safety warnings
303
+ #
304
+ # @example Safe plugin
305
+ # PluginValidator.check_safety(MySafePlugin)
306
+ # #=> []
307
+ #
308
+ # @example Potentially dangerous plugin
309
+ # PluginValidator.check_safety(MyDangerousPlugin)
310
+ # #=> ["Uses system call in setup method"]
311
+ def check_safety(plugin_class)
312
+ warnings = []
313
+
314
+ # Get source location
315
+ begin
316
+ methods_to_check = %i[setup activate metadata]
317
+
318
+ methods_to_check.each do |method_name|
319
+ next unless plugin_class.method_defined?(method_name, false)
320
+
321
+ method_obj = plugin_class.instance_method(method_name)
322
+ source_location = method_obj.source_location
323
+
324
+ if source_location && File.exist?(source_location[0])
325
+ source = File.read(source_location[0])
326
+
327
+ DANGEROUS_METHODS.each do |dangerous|
328
+ pattern = /\b#{Regexp.escape(dangerous)}\b/
329
+ if source&.match?(pattern)
330
+ warnings << "Plugin uses potentially dangerous method " \
331
+ "'#{dangerous}' " \
332
+ "in #{source_location[0]}"
333
+ end
334
+ end
335
+ end
336
+ end
337
+ rescue StandardError => e
338
+ warnings << "Could not perform safety check: #{e.message}"
339
+ end
340
+
341
+ warnings
342
+ end
343
+
344
+ private
345
+
346
+ # Check if a version string is valid
347
+ #
348
+ # @param version [String] Version string to check
349
+ #
350
+ # @return [Boolean] True if valid
351
+ def valid_version?(version)
352
+ version.is_a?(String) && version =~ /^\d+\.\d+(\.\d+)?$/
353
+ end
354
+
355
+ # Check if a URL is valid
356
+ #
357
+ # @param url [String] URL string to check
358
+ #
359
+ # @return [Boolean] True if valid
360
+ def valid_url?(url)
361
+ url.is_a?(String) && url =~ %r{^https?://}
362
+ end
363
+
364
+ # Check version compatibility
365
+ #
366
+ # @param actual [String] Actual version
367
+ # @param required [String] Required version
368
+ # @param constraint [Symbol] Constraint type (:gte, :pessimistic)
369
+ #
370
+ # @return [Boolean] True if compatible
371
+ def version_compatible?(actual, required, constraint)
372
+ actual_parts = actual.split(".").map(&:to_i)
373
+ required_parts = required.split(".").map(&:to_i)
374
+
375
+ case constraint
376
+ when :gte
377
+ compare_versions(actual_parts, required_parts) >= 0
378
+ when :pessimistic
379
+ # ~> 1.2 means >= 1.2 and < 2.0
380
+ # ~> 1.2.3 means >= 1.2.3 and < 1.3
381
+ return false if compare_versions(actual_parts,
382
+ required_parts).negative?
383
+
384
+ upper = required_parts.dup
385
+ if required_parts.length >= 3
386
+ # Patch-level constraint
387
+ upper[1] += 1
388
+ upper[2] = 0
389
+ else
390
+ # Minor-level constraint
391
+ upper[0] += 1
392
+ upper[1] = 0
393
+ end
394
+
395
+ compare_versions(actual_parts, upper).negative?
396
+ else
397
+ false
398
+ end
399
+ end
400
+
401
+ # Compare version part arrays
402
+ #
403
+ # @param v1 [Array<Integer>] Version 1 parts
404
+ # @param v2 [Array<Integer>] Version 2 parts
405
+ #
406
+ # @return [Integer] -1, 0, or 1
407
+ def compare_versions(v1, v2)
408
+ max_length = [v1.length, v2.length].max
409
+
410
+ max_length.times do |i|
411
+ p1 = v1[i] || 0
412
+ p2 = v2[i] || 0
413
+
414
+ return -1 if p1 < p2
415
+ return 1 if p1 > p2
416
+ end
417
+
418
+ 0
419
+ end
420
+ end
421
+ end
422
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "file_handle"
4
+ require_relative "memory_handle"
5
+
3
6
  module Cabriolet
4
7
  module System
5
8
  # IOSystem provides an abstraction layer for file I/O operations,
@@ -19,13 +19,19 @@ module Cabriolet
19
19
 
20
20
  # Read bytes from memory
21
21
  #
22
- # @param bytes [Integer] Number of bytes to read
22
+ # @param bytes [Integer, nil] Number of bytes to read (nil = read all remaining)
23
23
  # @return [String] Bytes read (binary encoding)
24
- def read(bytes)
24
+ def read(bytes = nil)
25
25
  return "" if @pos >= @data.bytesize
26
26
 
27
- result = @data.byteslice(@pos, bytes) || ""
28
- @pos += result.bytesize
27
+ if bytes.nil?
28
+ # Read all remaining data
29
+ result = @data.byteslice(@pos..-1) || ""
30
+ @pos = @data.bytesize
31
+ else
32
+ result = @data.byteslice(@pos, bytes) || ""
33
+ @pos += result.bytesize
34
+ end
29
35
  result
30
36
  end
31
37
 
@@ -77,6 +83,13 @@ module Cabriolet
77
83
  @pos
78
84
  end
79
85
 
86
+ # Rewind to the beginning of the handle
87
+ #
88
+ # @return [Integer] New position (0)
89
+ def rewind
90
+ @pos = 0
91
+ end
92
+
80
93
  # Close the handle
81
94
  #
82
95
  # @return [void]
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli/base_command_handler"
4
+ require_relative "decompressor"
5
+ require_relative "compressor"
6
+
7
+ module Cabriolet
8
+ module SZDD
9
+ # Command handler for SZDD (LZSS-compressed) format
10
+ #
11
+ # This handler implements the unified command interface for SZDD files,
12
+ # wrapping the existing SZDD::Decompressor and SZDD::Compressor classes.
13
+ #
14
+ class CommandHandler < Commands::BaseCommandHandler
15
+ # List SZDD file information
16
+ #
17
+ # For SZDD files, list displays detailed file information
18
+ # rather than a file listing (single file archive).
19
+ #
20
+ # @param file [String] Path to the SZDD file
21
+ # @param options [Hash] Additional options (unused)
22
+ # @return [void]
23
+ def list(file, _options = {})
24
+ validate_file_exists(file)
25
+
26
+ decompressor = Decompressor.new
27
+ header = decompressor.open(file)
28
+
29
+ display_szdd_info(header, file)
30
+
31
+ decompressor.close(header)
32
+ end
33
+
34
+ # Extract SZDD compressed file
35
+ #
36
+ # Expands the SZDD file to its original form.
37
+ # Auto-detects output filename if not specified.
38
+ #
39
+ # @param file [String] Path to the SZDD file
40
+ # @param output [String, nil] Output file path (or directory, for single-file extraction)
41
+ # @param options [Hash] Additional options
42
+ # @option options [String] :output Output file path
43
+ # @return [void]
44
+ def extract(file, output = nil, options = {})
45
+ validate_file_exists(file)
46
+
47
+ # Use output file from options if specified, otherwise use positional argument
48
+ output ||= options[:output]
49
+
50
+ # Auto-detect output name if not provided
51
+ output ||= auto_output_filename(file)
52
+
53
+ decompressor = Decompressor.new
54
+ header = decompressor.open(file)
55
+
56
+ puts "Expanding #{file} -> #{output}" if verbose?
57
+ bytes = decompressor.extract(header, output)
58
+ decompressor.close(header)
59
+
60
+ puts "Expanded #{file} to #{output} (#{bytes} bytes)"
61
+ end
62
+
63
+ # Create SZDD compressed file
64
+ #
65
+ # Compresses a file using SZDD (LZSS) compression.
66
+ #
67
+ # @param output [String] Output SZDD file path
68
+ # @param files [Array<String>] Input file (single file for SZDD)
69
+ # @param options [Hash] Additional options
70
+ # @option options [String] :missing_char Missing character for filename
71
+ # @option options [String] :szdd_format SZDD format (:normal, :qbasic)
72
+ # @return [void]
73
+ # @raise [ArgumentError] if no file specified or multiple files
74
+ def create(output, files = [], options = {})
75
+ raise ArgumentError, "No file specified" if files.empty?
76
+
77
+ if files.size > 1
78
+ raise ArgumentError,
79
+ "SZDD format supports only one file at a time"
80
+ end
81
+
82
+ file = files.first
83
+ unless File.exist?(file)
84
+ raise ArgumentError,
85
+ "File does not exist: #{file}"
86
+ end
87
+
88
+ format = parse_format_option(options[:szdd_format])
89
+ compress_options = { format: format }
90
+ if options[:missing_char]
91
+ compress_options[:missing_char] =
92
+ options[:missing_char]
93
+ end
94
+
95
+ # Auto-generate output name if not provided
96
+ if output.nil?
97
+ output = auto_generate_output(file)
98
+ end
99
+
100
+ compressor = Compressor.new
101
+
102
+ puts "Compressing #{file} -> #{output}" if verbose?
103
+ bytes = compressor.compress(file, output, **compress_options)
104
+
105
+ puts "Compressed #{file} to #{output} (#{bytes} bytes)"
106
+ end
107
+
108
+ # Display detailed SZDD file information
109
+ #
110
+ # @param file [String] Path to the SZDD file
111
+ # @param options [Hash] Additional options (unused)
112
+ # @return [void]
113
+ def info(file, _options = {})
114
+ validate_file_exists(file)
115
+
116
+ decompressor = Decompressor.new
117
+ header = decompressor.open(file)
118
+
119
+ display_szdd_info(header, file)
120
+
121
+ decompressor.close(header)
122
+ end
123
+
124
+ # Test SZDD file integrity
125
+ #
126
+ # Verifies the SZDD file structure.
127
+ #
128
+ # @param file [String] Path to the SZDD file
129
+ # @param options [Hash] Additional options (unused)
130
+ # @return [void]
131
+ def test(file, _options = {})
132
+ validate_file_exists(file)
133
+
134
+ decompressor = Decompressor.new
135
+ header = decompressor.open(file)
136
+
137
+ puts "Testing #{file}..."
138
+ # TODO: Implement full integrity testing
139
+ puts "OK: SZDD file structure is valid"
140
+ puts "Format: #{header.format.to_s.upcase}"
141
+ puts "Uncompressed size: #{header.length} bytes"
142
+
143
+ decompressor.close(header)
144
+ end
145
+
146
+ private
147
+
148
+ # Display SZDD file information
149
+ #
150
+ # @param header [Header] The SZDD header object
151
+ # @param file [String] Original file path
152
+ # @return [void]
153
+ def display_szdd_info(header, file)
154
+ puts "SZDD File Information"
155
+ puts "=" * 50
156
+ puts "Filename: #{file}"
157
+ puts "Format: #{header.format.to_s.upcase}"
158
+ puts "Uncompressed size: #{header.length} bytes"
159
+ if header.missing_char
160
+ puts "Missing character: '#{header.missing_char}'"
161
+ puts "Suggested filename: #{header.suggested_filename(File.basename(file))}"
162
+ end
163
+ end
164
+
165
+ # Auto-detect output filename from SZDD header
166
+ #
167
+ # @param file [String] Original file path
168
+ # @return [String] Detected output filename
169
+ def auto_output_filename(file)
170
+ decompressor = Decompressor.new
171
+ header = decompressor.open(file)
172
+ output = decompressor.auto_output_filename(file, header)
173
+ decompressor.close(header)
174
+ output
175
+ end
176
+
177
+ # Auto-generate output filename for SZDD
178
+ #
179
+ # SZDD convention: file.txt -> file.tx_
180
+ #
181
+ # @param file [String] Original file path
182
+ # @return [String] Generated output filename
183
+ def auto_generate_output(file)
184
+ # Replace extension last character with underscore
185
+ # file.txt -> file.tx_
186
+ ext = File.extname(file)
187
+ if ext.length == 2 # Single char extension like .c
188
+ base = File.basename(file, ext)
189
+ output = "#{base}#{ext[0]}_"
190
+ else
191
+ # For no extension or multi-char extension, just append _
192
+ output = "#{file}_"
193
+ end
194
+ output
195
+ end
196
+
197
+ # Parse format option to symbol
198
+ #
199
+ # @param format_value [String, Symbol] The format type
200
+ # @return [Symbol] The format symbol
201
+ def parse_format_option(format_value)
202
+ return :normal if format_value.nil?
203
+
204
+ format = format_value.to_sym
205
+ valid_formats = %i[normal qbasic]
206
+
207
+ unless valid_formats.include?(format)
208
+ raise ArgumentError,
209
+ "Invalid SZDD format: #{format_value}. " \
210
+ "Valid options: #{valid_formats.join(', ')}"
211
+ end
212
+
213
+ format
214
+ end
215
+ end
216
+ end
217
+ end