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
@@ -13,8 +13,9 @@ module Cabriolet
13
13
 
14
14
  attr_reader :io_system, :chm
15
15
 
16
- def initialize(io_system = nil)
16
+ def initialize(io_system = nil, algorithm_factory = nil)
17
17
  @io_system = io_system || System::IOSystem.new
18
+ @algorithm_factory = algorithm_factory || Cabriolet.algorithm_factory
18
19
  @chm = nil
19
20
  @input_handle = nil
20
21
  @lzx_state = nil
@@ -72,7 +73,7 @@ module Cabriolet
72
73
  when 1
73
74
  extract_compressed(file, output_path)
74
75
  else
75
- raise Errors::FormatError, "Invalid section ID: #{file.section.id}"
76
+ raise Cabriolet::FormatError, "Invalid section ID: #{file.section.id}"
76
77
  end
77
78
  end
78
79
 
@@ -108,13 +109,20 @@ module Cabriolet
108
109
  while remaining.positive?
109
110
  chunk_size = [buffer_size, remaining].min
110
111
  data = @input_handle.read(chunk_size)
111
- if data.nil? || data.length < chunk_size
112
- raise Errors::ReadError,
112
+ if data.nil?
113
+ raise Cabriolet::ReadError,
114
+ "Unexpected end of file"
115
+ end
116
+
117
+ # It's OK if we read less than chunk_size (e.g., last chunk or EOF)
118
+ # Only raise an error if we read nothing when we expected data
119
+ if data.empty? && remaining.positive?
120
+ raise Cabriolet::ReadError,
113
121
  "Unexpected end of file"
114
122
  end
115
123
 
116
124
  output_handle.write(data)
117
- remaining -= chunk_size
125
+ remaining -= data.length
118
126
  end
119
127
 
120
128
  output_handle.close
@@ -174,15 +182,18 @@ module Cabriolet
174
182
  control = sec.control || find_system_file(Parser::CONTROL_NAME)
175
183
 
176
184
  unless content
177
- raise Errors::FormatError,
185
+ raise Cabriolet::FormatError,
178
186
  "MSCompressed Content file not found"
179
187
  end
180
- raise Errors::FormatError, "ControlData file not found" unless control
188
+ unless control
189
+ raise Cabriolet::FormatError,
190
+ "ControlData file not found"
191
+ end
181
192
 
182
193
  # Read control data
183
194
  control_data = read_system_file(control)
184
195
  unless control_data.length == 28
185
- raise Errors::FormatError,
196
+ raise Cabriolet::FormatError,
186
197
  "ControlData wrong size"
187
198
  end
188
199
 
@@ -198,13 +209,14 @@ module Cabriolet
198
209
  when 0x100000 then 20
199
210
  when 0x200000 then 21
200
211
  else
201
- raise Errors::FormatError,
212
+ raise Cabriolet::FormatError,
202
213
  "Invalid window size: #{window_size}"
203
214
  end
204
215
 
205
216
  # Validate reset interval
206
217
  if reset_interval.zero? || (reset_interval % LZX_FRAME_SIZE) != 0
207
- raise Errors::FormatError, "Invalid reset interval: #{reset_interval}"
218
+ raise Cabriolet::FormatError,
219
+ "Invalid reset interval: #{reset_interval}"
208
220
  end
209
221
 
210
222
  # Find reset table entry for this file
@@ -227,7 +239,9 @@ module Cabriolet
227
239
  output_handle = System::MemoryHandle.new("")
228
240
 
229
241
  # Initialize LZX decompressor
230
- @lzx_state = Decompressors::LZX.new(
242
+ @lzx_state = @algorithm_factory.create(
243
+ Constants::COMP_TYPE_LZX,
244
+ :decompressor,
231
245
  @io_system,
232
246
  @input_handle,
233
247
  output_handle,
@@ -242,7 +256,7 @@ module Cabriolet
242
256
  def parse_control_data(data)
243
257
  signature = data[4, 4]
244
258
  unless signature == "LZXC"
245
- raise Errors::SignatureError,
259
+ raise Cabriolet::SignatureError,
246
260
  "Invalid LZXC signature"
247
261
  end
248
262
 
@@ -255,7 +269,8 @@ module Cabriolet
255
269
  reset_interval *= LZX_FRAME_SIZE
256
270
  window_size *= LZX_FRAME_SIZE
257
271
  elsif version != 1
258
- raise Errors::FormatError, "Unknown ControlData version: #{version}"
272
+ raise Cabriolet::FormatError,
273
+ "Unknown ControlData version: #{version}"
259
274
  end
260
275
 
261
276
  [window_size, reset_interval]
@@ -272,7 +287,7 @@ module Cabriolet
272
287
  # Fall back to SpanInfo
273
288
  spaninfo = sec.spaninfo || find_system_file(Parser::SPANINFO_NAME)
274
289
  unless spaninfo
275
- raise Errors::FormatError,
290
+ raise Cabriolet::FormatError,
276
291
  "Neither ResetTable nor SpanInfo found"
277
292
  end
278
293
 
@@ -284,12 +299,12 @@ module Cabriolet
284
299
  # Read an entry from the reset table
285
300
  def read_reset_table_entry(rtable, entry, reset_interval)
286
301
  data = read_system_file(rtable)
287
- raise Errors::FormatError, "ResetTable too short" if data.length < 40
302
+ raise Cabriolet::FormatError, "ResetTable too short" if data.length < 40
288
303
 
289
304
  # Check frame length
290
305
  frame_len = data[32, 8].unpack1("Q<")
291
306
  unless frame_len == LZX_FRAME_SIZE
292
- raise Errors::FormatError,
307
+ raise Cabriolet::FormatError,
293
308
  "Invalid frame length"
294
309
  end
295
310
 
@@ -307,7 +322,7 @@ module Cabriolet
307
322
  when 4 then data[pos, 4].unpack1("V")
308
323
  when 8 then data[pos, 8].unpack1("Q<")
309
324
  else
310
- raise Errors::FormatError,
325
+ raise Cabriolet::FormatError,
311
326
  "Invalid entry size: #{entry_size}"
312
327
  end
313
328
 
@@ -325,11 +340,14 @@ module Cabriolet
325
340
  # Read SpanInfo to get uncompressed length
326
341
  def read_spaninfo(spaninfo)
327
342
  data = read_system_file(spaninfo)
328
- raise Errors::FormatError, "SpanInfo wrong size" unless data.length == 8
343
+ unless data.length == 8
344
+ raise Cabriolet::FormatError,
345
+ "SpanInfo wrong size"
346
+ end
329
347
 
330
348
  length = data.unpack1("Q<")
331
349
  unless length.positive?
332
- raise Errors::FormatError,
350
+ raise Cabriolet::FormatError,
333
351
  "Invalid SpanInfo length"
334
352
  end
335
353
 
@@ -350,7 +368,7 @@ module Cabriolet
350
368
  # Read a system file's contents
351
369
  def read_system_file(file)
352
370
  unless file.section.id.zero?
353
- raise Errors::FormatError,
371
+ raise Cabriolet::FormatError,
354
372
  "System file must be in section 0"
355
373
  end
356
374
 
@@ -417,7 +435,7 @@ module Cabriolet
417
435
  file.length = length
418
436
  return file
419
437
  end
420
- rescue Errors::FormatError
438
+ rescue Cabriolet::FormatError
421
439
  break
422
440
  end
423
441
  end
@@ -56,9 +56,12 @@ module Cabriolet
56
56
  end
57
57
 
58
58
  # Check GUIDs
59
- unless header.guid1 == GUID1 && header.guid2 == GUID2
59
+ # Note: Some CHM files have both GUIDs set to GUID1 (unusual but valid)
60
+ # Standard files have GUID1 and GUID2 as expected
61
+ # We validate that guid2 matches either GUID1 or GUID2
62
+ unless [GUID1, GUID2].include?(header.guid2)
60
63
  raise SignatureError,
61
- "Invalid CHM GUIDs"
64
+ "Invalid CHM GUIDs (guid2 should match CHM format GUID)"
62
65
  end
63
66
 
64
67
  @chm.version = header.version
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module Commands
5
+ # Abstract base class for format-specific command handlers
6
+ #
7
+ # This class defines the interface that all format command handlers must implement.
8
+ # Each format (CAB, CHM, SZDD, KWAJ, HLP, LIT, OAB) should have its own
9
+ # CommandHandler subclass that inherits from this base class.
10
+ #
11
+ # The base class provides common functionality and enforces a consistent
12
+ # interface across all format handlers, following the Template Method pattern.
13
+ #
14
+ # @example Creating a format handler
15
+ # module Cabriolet
16
+ # module CAB
17
+ # class CommandHandler < CLI::BaseCommandHandler
18
+ # def list(file, options = {})
19
+ # # Implementation for listing CAB files
20
+ # end
21
+ # end
22
+ # end
23
+ # end
24
+ #
25
+ class BaseCommandHandler
26
+ # Initialize the command handler
27
+ #
28
+ # @param verbose [Boolean] Enable verbose output
29
+ def initialize(verbose: false)
30
+ @verbose = verbose
31
+ end
32
+
33
+ # List archive contents
34
+ #
35
+ # @param file [String] Path to the archive file
36
+ # @param options [Hash] Additional options
37
+ # @raise [NotImplementedError] Subclass must implement
38
+ def list(file, options = {})
39
+ raise NotImplementedError,
40
+ "#{self.class} must implement #list"
41
+ end
42
+
43
+ # Extract files from archive
44
+ #
45
+ # @param file [String] Path to the archive file
46
+ # @param output_dir [String] Output directory path
47
+ # @param options [Hash] Additional options
48
+ # @raise [NotImplementedError] Subclass must implement
49
+ def extract(file, output_dir, options = {})
50
+ raise NotImplementedError,
51
+ "#{self.class} must implement #extract"
52
+ end
53
+
54
+ # Create a new archive
55
+ #
56
+ # @param output [String] Output file path
57
+ # @param files [Array<String>] List of input files
58
+ # @param options [Hash] Additional options
59
+ # @raise [NotImplementedError] Subclass must implement
60
+ def create(output, files, options = {})
61
+ raise NotImplementedError,
62
+ "#{self.class} must implement #create"
63
+ end
64
+
65
+ # Display archive information
66
+ #
67
+ # @param file [String] Path to the archive file
68
+ # @param options [Hash] Additional options
69
+ # @raise [NotImplementedError] Subclass must implement
70
+ def info(file, options = {})
71
+ raise NotImplementedError,
72
+ "#{self.class} must implement #info"
73
+ end
74
+
75
+ # Test archive integrity
76
+ #
77
+ # @param file [String] Path to the archive file
78
+ # @param options [Hash] Additional options
79
+ # @raise [NotImplementedError] Subclass must implement
80
+ def test(file, options = {})
81
+ raise NotImplementedError,
82
+ "#{self.class} must implement #test"
83
+ end
84
+
85
+ protected
86
+
87
+ # Check if verbose output is enabled
88
+ #
89
+ # @return [Boolean] true if verbose mode is active
90
+ def verbose?
91
+ @verbose
92
+ end
93
+
94
+ # Detect format from file using FormatDetector
95
+ #
96
+ # This is a convenience method for handlers that need to perform
97
+ # format detection within their operations.
98
+ #
99
+ # @param file [String] Path to the file
100
+ # @return [Symbol, nil] Detected format symbol
101
+ def detect_format(file)
102
+ require_relative "../format_detector"
103
+ FormatDetector.detect(file)
104
+ end
105
+
106
+ # Validate that a file exists
107
+ #
108
+ # @param file [String] Path to the file
109
+ # @raise [ArgumentError] if file doesn't exist
110
+ def validate_file_exists(file)
111
+ return if File.exist?(file)
112
+
113
+ raise ArgumentError, "File does not exist: #{file}"
114
+ end
115
+
116
+ # Ensure output directory exists
117
+ #
118
+ # @param output_dir [String] Output directory path
119
+ # @return [String] The output directory path
120
+ def ensure_output_dir(output_dir)
121
+ require "fileutils"
122
+ FileUtils.mkdir_p(output_dir)
123
+ output_dir
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_registry"
4
+ require_relative "base_command_handler"
5
+ require_relative "../format_detector"
6
+
7
+ module Cabriolet
8
+ module Commands
9
+ # Unified command dispatcher that routes commands to format-specific handlers
10
+ #
11
+ # The dispatcher is responsible for:
12
+ # 1. Detecting the format of input files (or using manual override)
13
+ # 2. Selecting the appropriate format handler from the registry
14
+ # 3. Delegating command execution to the handler
15
+ #
16
+ # This class implements the Strategy pattern, where the format handler
17
+ # is the strategy selected based on the detected format.
18
+ #
19
+ # @example Using the dispatcher
20
+ # dispatcher = CLI::CommandDispatcher.new(format: :cab, verbose: true)
21
+ # dispatcher.dispatch(:list, "archive.cab")
22
+ #
23
+ class CommandDispatcher
24
+ # Initialize the command dispatcher
25
+ #
26
+ # @param options [Hash] Configuration options
27
+ # @option options [String, Symbol] :format Manual format override
28
+ # @option options [Boolean] :verbose Enable verbose output
29
+ def initialize(options = {})
30
+ @format_override = parse_format_option(options[:format])
31
+ @verbose = options[:verbose] || false
32
+ end
33
+
34
+ # Dispatch a command to the appropriate format handler
35
+ #
36
+ # This method detects the format (if not manually specified),
37
+ # retrieves the appropriate handler, and delegates the command execution.
38
+ #
39
+ # @param command [Symbol] The command to execute (:list, :extract, etc.)
40
+ # @param file [String] Path to the archive file
41
+ # @param args [Array] Additional positional arguments for the command
42
+ # @param options [Hash] Additional options to pass to the handler
43
+ # @raise [Cabriolet::Error] if format detection fails or handler not found
44
+ # @return [void]
45
+ def dispatch(command, file, *args, **options)
46
+ format = detect_format(file)
47
+ handler = get_handler_for(format)
48
+
49
+ execute_command(handler, command, file, args, options)
50
+ end
51
+
52
+ # Check if a format is supported
53
+ #
54
+ # @param format [Symbol] The format to check
55
+ # @return [Boolean] true if the format has a registered handler
56
+ def self.format_supported?(format)
57
+ CommandRegistry.format_registered?(format)
58
+ end
59
+
60
+ # Get list of supported formats
61
+ #
62
+ # @return [Array<Symbol>] List of supported format symbols
63
+ def self.supported_formats
64
+ CommandRegistry.registered_formats
65
+ end
66
+
67
+ private
68
+
69
+ # Parse format option to symbol
70
+ #
71
+ # @param format_value [String, Symbol, nil] The format option value
72
+ # @return [Symbol, nil] The format as a symbol
73
+ def parse_format_option(format_value)
74
+ return nil if format_value.nil?
75
+
76
+ format_value.to_sym
77
+ end
78
+
79
+ # Detect format from file with fallback to manual override
80
+ #
81
+ # @param file [String] Path to the archive file
82
+ # @return [Symbol] The detected format symbol
83
+ # @raise [Cabriolet::Error] if format cannot be detected
84
+ def detect_format(file)
85
+ return @format_override if @format_override
86
+
87
+ format = FormatDetector.detect(file)
88
+ if format.nil?
89
+ supported = CommandRegistry.registered_formats.join(", ")
90
+ raise Error,
91
+ "Cannot detect format for: #{file}. " \
92
+ "Use --format to specify (supported: #{supported})"
93
+ end
94
+
95
+ format
96
+ end
97
+
98
+ # Get the handler class for a format
99
+ #
100
+ # @param format [Symbol] The format symbol
101
+ # @return [Class] The handler class
102
+ # @raise [Cabriolet::Error] if no handler is registered
103
+ def get_handler_for(format)
104
+ handler = CommandRegistry.handler_for(format)
105
+
106
+ unless handler
107
+ raise Error,
108
+ "No command handler registered for format: #{format}"
109
+ end
110
+
111
+ handler
112
+ end
113
+
114
+ # Execute the command on the handler
115
+ #
116
+ # @param handler_class [Class] The handler class
117
+ # @param command [Symbol] The command to execute
118
+ # @param file [String] Path to the archive file
119
+ # @param args [Array] Additional positional arguments
120
+ # @param options [Hash] Additional options
121
+ # @return [void]
122
+ def execute_command(handler_class, command, file, args, options)
123
+ handler = handler_class.new(verbose: @verbose)
124
+
125
+ # Call the command method with appropriate arguments
126
+ case command
127
+ when :extract
128
+ output_dir = args.first || options[:output_dir]
129
+ handler.extract(file, output_dir, options)
130
+ when :create
131
+ files = args.first || options[:files] || []
132
+ handler.create(file, files, options)
133
+ else
134
+ # For commands that take just file and options
135
+ handler.public_send(command, file, options)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module Commands
5
+ # Registry for mapping format symbols to command handler classes
6
+ #
7
+ # This registry provides a centralized location for managing format handlers,
8
+ # following the Open/Closed Principle - new formats can be added without
9
+ # modifying existing command logic.
10
+ #
11
+ # @example Registering a format handler
12
+ # CLI::CommandRegistry.register_format(:cab, CAB::CommandHandler)
13
+ #
14
+ # @example Getting a handler for a format
15
+ # handler = CLI::CommandRegistry.handler_for(:cab)
16
+ #
17
+ class CommandRegistry
18
+ @handlers = {}
19
+
20
+ class << self
21
+ # Get the command handler class for a given format
22
+ #
23
+ # @param format [Symbol] The format symbol (e.g., :cab, :chm, :szdd)
24
+ # @return [Class, nil] The handler class or nil if not registered
25
+ def handler_for(format)
26
+ @handlers[format]
27
+ end
28
+
29
+ # Register a command handler for a format
30
+ #
31
+ # This allows for dynamic registration of format handlers,
32
+ # supporting extensibility and plugin architectures.
33
+ #
34
+ # @param format [Symbol] The format symbol
35
+ # @param handler_class [Class] The command handler class
36
+ # @raise [ArgumentError] if handler_class doesn't implement required interface
37
+ def register_format(format, handler_class)
38
+ validate_handler_interface(handler_class)
39
+ @handlers[format] = handler_class
40
+ end
41
+
42
+ # Get all registered formats
43
+ #
44
+ # @return [Array<Symbol>] List of registered format symbols
45
+ def registered_formats
46
+ @handlers.keys
47
+ end
48
+
49
+ # Check if a format is registered
50
+ #
51
+ # @param format [Symbol] The format symbol
52
+ # @return [Boolean] true if the format has a registered handler
53
+ def format_registered?(format)
54
+ @handlers.key?(format)
55
+ end
56
+
57
+ # Clear all registered formats (primarily for testing)
58
+ #
59
+ # @return [void]
60
+ def clear
61
+ @handlers = {}
62
+ end
63
+
64
+ private
65
+
66
+ # Validate that a handler class implements the required interface
67
+ #
68
+ # @param handler_class [Class] The class to validate
69
+ # @raise [ArgumentError] if the class doesn't implement required methods
70
+ def validate_handler_interface(handler_class)
71
+ required_methods = %i[list extract create info test]
72
+
73
+ required_methods.each do |method|
74
+ unless handler_class.method_defined?(method)
75
+ raise ArgumentError,
76
+ "Handler class #{handler_class} must implement ##{method}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end