magick-feature-flags 0.7.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +363 -0
  4. data/lib/generators/magick/install/install_generator.rb +19 -0
  5. data/lib/generators/magick/install/templates/README +25 -0
  6. data/lib/generators/magick/install/templates/magick.rb +32 -0
  7. data/lib/magick/adapters/base.rb +27 -0
  8. data/lib/magick/adapters/memory.rb +113 -0
  9. data/lib/magick/adapters/redis.rb +97 -0
  10. data/lib/magick/adapters/registry.rb +133 -0
  11. data/lib/magick/audit_log.rb +65 -0
  12. data/lib/magick/circuit_breaker.rb +65 -0
  13. data/lib/magick/config.rb +179 -0
  14. data/lib/magick/dsl.rb +80 -0
  15. data/lib/magick/errors.rb +9 -0
  16. data/lib/magick/export_import.rb +82 -0
  17. data/lib/magick/feature.rb +665 -0
  18. data/lib/magick/feature_dependency.rb +28 -0
  19. data/lib/magick/feature_variant.rb +17 -0
  20. data/lib/magick/performance_metrics.rb +76 -0
  21. data/lib/magick/rails/event_subscriber.rb +55 -0
  22. data/lib/magick/rails/events.rb +236 -0
  23. data/lib/magick/rails/railtie.rb +94 -0
  24. data/lib/magick/rails.rb +7 -0
  25. data/lib/magick/targeting/base.rb +11 -0
  26. data/lib/magick/targeting/complex.rb +27 -0
  27. data/lib/magick/targeting/custom_attribute.rb +35 -0
  28. data/lib/magick/targeting/date_range.rb +17 -0
  29. data/lib/magick/targeting/group.rb +15 -0
  30. data/lib/magick/targeting/ip_address.rb +22 -0
  31. data/lib/magick/targeting/percentage.rb +24 -0
  32. data/lib/magick/targeting/request_percentage.rb +15 -0
  33. data/lib/magick/targeting/role.rb +15 -0
  34. data/lib/magick/targeting/user.rb +15 -0
  35. data/lib/magick/testing_helpers.rb +45 -0
  36. data/lib/magick/version.rb +5 -0
  37. data/lib/magick/versioning.rb +98 -0
  38. data/lib/magick.rb +143 -0
  39. metadata +123 -0
@@ -0,0 +1,665 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../magick/feature_variant'
5
+
6
+ module Magick
7
+ class Feature
8
+ VALID_TYPES = %i[boolean string number].freeze
9
+ VALID_STATUSES = %i[active inactive deprecated].freeze
10
+
11
+ attr_reader :name, :type, :status, :default_value, :description, :adapter_registry
12
+
13
+ def initialize(name, adapter_registry, **options)
14
+ @name = name.to_s
15
+ @adapter_registry = adapter_registry
16
+ @type = (options[:type] || :boolean).to_sym
17
+ @status = (options[:status] || :active).to_sym
18
+ @default_value = options.fetch(:default_value, default_for_type)
19
+ @description = options[:description]
20
+ @targeting = {}
21
+ @dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
22
+
23
+ validate_type!
24
+ validate_default_value!
25
+ load_from_adapter
26
+ end
27
+
28
+ def enabled?(context = {})
29
+ # Track usage if metrics available
30
+ start_time = Time.now if Magick.performance_metrics
31
+
32
+ result = check_enabled(context)
33
+
34
+ if Magick.performance_metrics
35
+ duration = (Time.now - start_time) * 1000 # milliseconds
36
+ Magick.performance_metrics.record(name, 'enabled?', duration, success: true)
37
+ end
38
+
39
+ # Rails 8+ events for enabled/disabled
40
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
41
+ if result
42
+ Magick::Rails::Events.feature_enabled(name, context: context)
43
+ else
44
+ Magick::Rails::Events.feature_disabled(name, context: context)
45
+ end
46
+ end
47
+
48
+ # Warn if deprecated
49
+ if status == :deprecated && result && !context[:allow_deprecated]
50
+ warn "DEPRECATED: Feature '#{name}' is deprecated and will be removed." if Magick.warn_on_deprecated
51
+
52
+ # Rails 8+ event for deprecation warning
53
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
54
+ Magick::Rails::Events.deprecated_warning(name)
55
+ end
56
+ end
57
+
58
+ result
59
+ rescue StandardError => e
60
+ if Magick.performance_metrics
61
+ duration = (Time.now - start_time) * 1000
62
+ Magick.performance_metrics.record(name, 'enabled?', duration, success: false)
63
+ end
64
+ raise e
65
+ end
66
+
67
+ def check_enabled(context = {})
68
+ return false if status == :inactive
69
+ return false if status == :deprecated && !context[:allow_deprecated]
70
+
71
+ # Check feature dependencies
72
+ unless dependencies.empty?
73
+ return false unless dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
74
+ end
75
+
76
+ # Check date/time range targeting
77
+ if targeting[:date_range] && !date_range_active?(targeting[:date_range])
78
+ return false
79
+ end
80
+
81
+ # Check IP address targeting
82
+ if targeting[:ip_address] && context[:ip_address]
83
+ return false unless ip_address_matches?(context[:ip_address])
84
+ end
85
+
86
+ # Check custom attributes
87
+ if targeting[:custom_attributes]
88
+ return false unless custom_attributes_match?(context, targeting[:custom_attributes])
89
+ end
90
+
91
+ # Check complex conditions
92
+ if targeting[:complex_conditions]
93
+ return false unless complex_conditions_match?(context, targeting[:complex_conditions])
94
+ end
95
+
96
+ value = get_value(context)
97
+ case type
98
+ when :boolean
99
+ value == true
100
+ when :string
101
+ !value.nil? && value != ''
102
+ when :number
103
+ value.to_f > 0
104
+ else
105
+ false
106
+ end
107
+ end
108
+
109
+ def disabled?(context = {})
110
+ !enabled?(context)
111
+ end
112
+
113
+ def value(context = {})
114
+ get_value(context)
115
+ end
116
+
117
+ def get_value(context = {})
118
+ # Check targeting rules first
119
+ targeting_result = check_targeting(context)
120
+ return targeting_result unless targeting_result.nil?
121
+
122
+ # Fall back to stored value or default
123
+ stored_value || default_value
124
+ end
125
+
126
+ def enable_for_user(user_id)
127
+ enable_targeting(:user, user_id)
128
+ end
129
+
130
+ def disable_for_user(user_id)
131
+ disable_targeting(:user, user_id)
132
+ end
133
+
134
+ def enable_for_group(group_name)
135
+ enable_targeting(:group, group_name)
136
+ end
137
+
138
+ def disable_for_group(group_name)
139
+ disable_targeting(:group, group_name)
140
+ end
141
+
142
+ def enable_for_role(role_name)
143
+ enable_targeting(:role, role_name)
144
+ end
145
+
146
+ def disable_for_role(role_name)
147
+ disable_targeting(:role, role_name)
148
+ end
149
+
150
+ def enable_percentage_of_users(percentage)
151
+ @targeting[:percentage_users] = percentage.to_f
152
+ save_targeting
153
+
154
+ # Update registered feature instance if it exists
155
+ if Magick.features.key?(name)
156
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
157
+ end
158
+
159
+ # Rails 8+ event
160
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
161
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
162
+ end
163
+ end
164
+
165
+ def disable_percentage_of_users
166
+ disable_targeting(:percentage_users)
167
+ end
168
+
169
+ def enable_percentage_of_requests(percentage)
170
+ @targeting[:percentage_requests] = percentage.to_f
171
+ save_targeting
172
+
173
+ # Update registered feature instance if it exists
174
+ if Magick.features.key?(name)
175
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
176
+ end
177
+
178
+ # Rails 8+ event
179
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
180
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
181
+ end
182
+ end
183
+
184
+ def disable_percentage_of_requests
185
+ disable_targeting(:percentage_requests)
186
+ end
187
+
188
+ def enable_for_date_range(start_date, end_date)
189
+ enable_targeting(:date_range, { start: start_date, end: end_date })
190
+ end
191
+
192
+ def disable_date_range
193
+ disable_targeting(:date_range)
194
+ end
195
+
196
+ def enable_for_ip_addresses(ip_addresses)
197
+ enable_targeting(:ip_address, Array(ip_addresses))
198
+ end
199
+
200
+ def disable_ip_addresses
201
+ disable_targeting(:ip_address)
202
+ end
203
+
204
+ def enable_for_custom_attribute(attribute_name, values, operator: :equals)
205
+ custom_attrs = targeting[:custom_attributes] || {}
206
+ custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
207
+ enable_targeting(:custom_attributes, custom_attrs)
208
+ end
209
+
210
+ def disable_custom_attribute(attribute_name)
211
+ custom_attrs = targeting[:custom_attributes] || {}
212
+ custom_attrs.delete(attribute_name.to_sym)
213
+ if custom_attrs.empty?
214
+ disable_targeting(:custom_attributes)
215
+ else
216
+ enable_targeting(:custom_attributes, custom_attrs)
217
+ end
218
+ end
219
+
220
+ def set_variants(variants)
221
+ variants_array = Array(variants).map do |v|
222
+ v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
223
+ end
224
+ enable_targeting(:variants, variants_array.map(&:to_h))
225
+
226
+ # Rails 8+ event
227
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
228
+ Magick::Rails::Events.variant_set(name, variants: variants_array)
229
+ end
230
+ end
231
+
232
+ def add_dependency(dependency_name)
233
+ @dependencies ||= []
234
+ @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
235
+
236
+ # Rails 8+ event
237
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
238
+ Magick::Rails::Events.dependency_added(name, dependency_name)
239
+ end
240
+ end
241
+
242
+ def remove_dependency(dependency_name)
243
+ @dependencies&.delete(dependency_name.to_s)
244
+
245
+ # Rails 8+ event
246
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
247
+ Magick::Rails::Events.dependency_removed(name, dependency_name)
248
+ end
249
+ end
250
+
251
+ def dependencies
252
+ @dependencies || []
253
+ end
254
+
255
+ def get_variant(context = {})
256
+ return nil unless targeting[:variants]
257
+
258
+ variants = targeting[:variants]
259
+ selected_variant = if variants.length == 1
260
+ variants.first[:name]
261
+ else
262
+ # Weighted random selection
263
+ total_weight = variants.sum { |v| v[:weight] || 0 }
264
+ if total_weight == 0
265
+ variants.first[:name]
266
+ else
267
+ random = rand(total_weight)
268
+ current = 0
269
+ selected = nil
270
+ variants.each do |variant|
271
+ current += (variant[:weight] || 0)
272
+ if random < current
273
+ selected = variant[:name]
274
+ break
275
+ end
276
+ end
277
+ selected || variants.first[:name]
278
+ end
279
+ end
280
+
281
+ # Rails 8+ event
282
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
283
+ Magick::Rails::Events.variant_selected(name, variant_name: selected_variant, context: context)
284
+ end
285
+
286
+ selected_variant
287
+ end
288
+
289
+ def set_value(value, user_id: nil)
290
+ old_value = @stored_value
291
+ validate_value!(value)
292
+ adapter_registry.set(name, 'value', value)
293
+ adapter_registry.set(name, 'type', type)
294
+ adapter_registry.set(name, 'status', status)
295
+ adapter_registry.set(name, 'default_value', default_value)
296
+ adapter_registry.set(name, 'description', description) if description
297
+ @stored_value = value
298
+
299
+ # Update registered feature instance if it exists
300
+ if Magick.features.key?(name)
301
+ Magick.features[name].instance_variable_set(:@stored_value, value)
302
+ end
303
+
304
+ changes = { value: { from: old_value, to: value } }
305
+
306
+ # Audit log
307
+ if Magick.audit_log
308
+ Magick.audit_log.log(
309
+ name,
310
+ 'set_value',
311
+ user_id: user_id,
312
+ changes: changes
313
+ )
314
+ end
315
+
316
+ # Rails 8+ events
317
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
318
+ Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
319
+ Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
320
+ end
321
+ end
322
+
323
+ def enable(user_id: nil)
324
+ # Clear all targeting to enable globally
325
+ @targeting = {}
326
+ save_targeting
327
+
328
+ case type
329
+ when :boolean
330
+ set_value(true, user_id: user_id)
331
+ when :string
332
+ raise InvalidFeatureValueError, "Cannot enable string feature. Use set_value instead."
333
+ when :number
334
+ raise InvalidFeatureValueError, "Cannot enable number feature. Use set_value instead."
335
+ else
336
+ raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
337
+ end
338
+
339
+ # Rails 8+ event
340
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
341
+ Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
342
+ end
343
+ end
344
+
345
+ def disable(user_id: nil)
346
+ # Clear all targeting to disable globally
347
+ @targeting = {}
348
+ save_targeting
349
+
350
+ case type
351
+ when :boolean
352
+ set_value(false, user_id: user_id)
353
+ when :string
354
+ set_value('', user_id: user_id)
355
+ when :number
356
+ set_value(0, user_id: user_id)
357
+ else
358
+ raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
359
+ end
360
+
361
+ # Rails 8+ event
362
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
363
+ Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
364
+ end
365
+ end
366
+
367
+ def set_status(new_status)
368
+ raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
369
+
370
+ @status = new_status.to_sym
371
+ adapter_registry.set(name, 'status', status)
372
+ end
373
+
374
+ def delete
375
+ adapter_registry.delete(name)
376
+ @stored_value = nil
377
+ @targeting = {}
378
+ # Also remove from Magick.features if registered
379
+ Magick.features.delete(name.to_s)
380
+ end
381
+
382
+ def to_h
383
+ {
384
+ name: name,
385
+ type: type,
386
+ status: status,
387
+ value: stored_value,
388
+ default_value: default_value,
389
+ description: description,
390
+ targeting: targeting
391
+ }
392
+ end
393
+
394
+ private
395
+
396
+ attr_reader :targeting
397
+
398
+ def stored_value
399
+ # Always go through adapter to check for cross-process updates via version checking
400
+ # The adapter registry will check Redis version and invalidate memory cache if stale
401
+ load_value_from_adapter
402
+ end
403
+
404
+ # Reload feature state from adapter (useful when feature is changed externally)
405
+ def reload
406
+ load_from_adapter
407
+ # Update registered feature instance if it exists
408
+ if Magick.features.key?(name)
409
+ registered = Magick.features[name]
410
+ registered.instance_variable_set(:@stored_value, @stored_value)
411
+ registered.instance_variable_set(:@status, @status)
412
+ registered.instance_variable_set(:@targeting, @targeting.dup)
413
+ end
414
+ self
415
+ end
416
+
417
+ def load_from_adapter
418
+ # Clear cached value to force reload from adapter (which checks version)
419
+ @stored_value = nil
420
+ @stored_value = load_value_from_adapter
421
+ status_value = adapter_registry.get(name, 'status')
422
+ @status = status_value ? status_value.to_sym : status
423
+ targeting_value = adapter_registry.get(name, 'targeting')
424
+ if targeting_value.is_a?(Hash)
425
+ # Normalize keys to symbols and handle nested structures
426
+ @targeting = targeting_value.transform_keys(&:to_sym)
427
+ # Handle percentage_users and percentage_requests which might be stored as numbers
428
+ @targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
429
+ @targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
430
+ else
431
+ @targeting = {}
432
+ end
433
+ end
434
+
435
+ def load_value_from_adapter
436
+ value = adapter_registry.get(name, 'value')
437
+ return nil if value.nil?
438
+
439
+ case type
440
+ when :boolean
441
+ value == true || value == 'true' || value == 1
442
+ when :string
443
+ value.to_s
444
+ when :number
445
+ value.to_f
446
+ else
447
+ value
448
+ end
449
+ end
450
+
451
+ def check_targeting(context)
452
+ return nil if targeting.empty?
453
+
454
+ # Normalize targeting keys (handle both string and symbol keys)
455
+ target = targeting.transform_keys(&:to_sym)
456
+
457
+ # Check user targeting
458
+ if context[:user_id] && target[:user]
459
+ user_list = target[:user].is_a?(Array) ? target[:user] : [target[:user]]
460
+ return true if user_list.include?(context[:user_id].to_s)
461
+ end
462
+
463
+ # Check group targeting
464
+ if context[:group] && target[:group]
465
+ group_list = target[:group].is_a?(Array) ? target[:group] : [target[:group]]
466
+ return true if group_list.include?(context[:group].to_s)
467
+ end
468
+
469
+ # Check role targeting
470
+ if context[:role] && target[:role]
471
+ role_list = target[:role].is_a?(Array) ? target[:role] : [target[:role]]
472
+ return true if role_list.include?(context[:role].to_s)
473
+ end
474
+
475
+ # Check percentage of users (consistent based on user_id)
476
+ if context[:user_id] && target[:percentage_users]
477
+ percentage = target[:percentage_users].to_f
478
+ return true if user_in_percentage?(context[:user_id], percentage)
479
+ end
480
+
481
+ # Check percentage of requests (random)
482
+ if target[:percentage_requests]
483
+ percentage = target[:percentage_requests].to_f
484
+ return true if rand(100) < percentage
485
+ end
486
+
487
+ nil
488
+ end
489
+
490
+ def date_range_active?(date_range_config)
491
+ return true unless date_range_config
492
+
493
+ start_date = date_range_config[:start] || date_range_config['start']
494
+ end_date = date_range_config[:end] || date_range_config['end']
495
+ return true unless start_date && end_date
496
+
497
+ start_time = start_date.is_a?(String) ? Time.parse(start_date) : start_date
498
+ end_time = end_date.is_a?(String) ? Time.parse(end_date) : end_date
499
+ now = Time.now
500
+ now >= start_time && now <= end_time
501
+ end
502
+
503
+ def ip_address_matches?(ip_address)
504
+ return false unless targeting[:ip_address]
505
+
506
+ require 'ipaddr'
507
+ ip_list = Array(targeting[:ip_address])
508
+ client_ip = IPAddr.new(ip_address)
509
+ ip_list.any? do |ip_str|
510
+ IPAddr.new(ip_str).include?(client_ip)
511
+ end
512
+ rescue IPAddr::InvalidAddressError
513
+ false
514
+ end
515
+
516
+ def custom_attributes_match?(context, custom_attrs_config)
517
+ return true unless custom_attrs_config
518
+
519
+ custom_attrs_config.all? do |attr_name, config|
520
+ context_value = context[attr_name] || context[attr_name.to_s]
521
+ next false if context_value.nil?
522
+
523
+ values = Array(config[:values] || config['values'])
524
+ operator = (config[:operator] || config['operator'] || :equals).to_sym
525
+
526
+ case operator
527
+ when :equals, :eq
528
+ values.include?(context_value.to_s)
529
+ when :not_equals, :ne
530
+ !values.include?(context_value.to_s)
531
+ when :in
532
+ values.include?(context_value.to_s)
533
+ when :not_in
534
+ !values.include?(context_value.to_s)
535
+ when :greater_than, :gt
536
+ context_value.to_f > values.first.to_f
537
+ when :less_than, :lt
538
+ context_value.to_f < values.first.to_f
539
+ else
540
+ false
541
+ end
542
+ end
543
+ end
544
+
545
+ def complex_conditions_match?(context, complex_config)
546
+ return true unless complex_config
547
+
548
+ conditions = complex_config[:conditions] || complex_config['conditions'] || []
549
+ operator = (complex_config[:operator] || complex_config['operator'] || :and).to_sym
550
+
551
+ results = conditions.map do |condition|
552
+ # Each condition is a hash with type and params
553
+ condition_type = (condition[:type] || condition['type']).to_sym
554
+ condition_params = condition[:params] || condition['params'] || {}
555
+
556
+ case condition_type
557
+ when :user
558
+ user_list = Array(condition_params[:user_ids] || condition_params['user_ids'])
559
+ user_list.include?(context[:user_id]&.to_s)
560
+ when :group
561
+ group_list = Array(condition_params[:groups] || condition_params['groups'])
562
+ group_list.include?(context[:group]&.to_s)
563
+ when :role
564
+ role_list = Array(condition_params[:roles] || condition_params['roles'])
565
+ role_list.include?(context[:role]&.to_s)
566
+ when :custom_attribute
567
+ attr_name = condition_params[:attribute] || condition_params['attribute']
568
+ attr_values = Array(condition_params[:values] || condition_params['values'])
569
+ attr_values.include?(context[attr_name]&.to_s)
570
+ else
571
+ false
572
+ end
573
+ end
574
+
575
+ case operator
576
+ when :and, :all
577
+ results.all?
578
+ when :or, :any
579
+ results.any?
580
+ else
581
+ false
582
+ end
583
+ end
584
+
585
+ def user_in_percentage?(user_id, percentage)
586
+ hash = Digest::MD5.hexdigest("#{name}:#{user_id}")
587
+ hash_value = hash[0..7].to_i(16)
588
+ (hash_value % 100) < percentage
589
+ end
590
+
591
+ def enable_targeting(type, value)
592
+ @targeting[type] ||= []
593
+ @targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
594
+ save_targeting
595
+
596
+ # Rails 8+ event
597
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
598
+ Magick::Rails::Events.targeting_added(name, targeting_type: type, targeting_value: value)
599
+ end
600
+ end
601
+
602
+ def disable_targeting(type, value = nil)
603
+ if value.nil?
604
+ @targeting.delete(type)
605
+ else
606
+ @targeting[type]&.delete(value.to_s)
607
+ end
608
+ save_targeting
609
+
610
+ # Rails 8+ event
611
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
612
+ Magick::Rails::Events.targeting_removed(name, targeting_type: type, targeting_value: value)
613
+ end
614
+ end
615
+
616
+ def save_targeting
617
+ adapter_registry.set(name, 'targeting', targeting)
618
+ # Update the feature in Magick.features if it's registered
619
+ if Magick.features.key?(name)
620
+ Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
621
+ end
622
+ end
623
+
624
+ def default_for_type
625
+ case type
626
+ when :boolean
627
+ false
628
+ when :string
629
+ ''
630
+ when :number
631
+ 0
632
+ else
633
+ false
634
+ end
635
+ end
636
+
637
+ def validate_type!
638
+ return if VALID_TYPES.include?(type)
639
+
640
+ raise InvalidFeatureTypeError, "Invalid feature type: #{type}. Valid types are: #{VALID_TYPES.join(', ')}"
641
+ end
642
+
643
+ def validate_default_value!
644
+ case type
645
+ when :boolean
646
+ raise InvalidFeatureValueError, "Default value must be boolean for type :boolean" unless [true, false].include?(default_value)
647
+ when :string
648
+ raise InvalidFeatureValueError, "Default value must be a string for type :string" unless default_value.is_a?(String)
649
+ when :number
650
+ raise InvalidFeatureValueError, "Default value must be numeric for type :number" unless default_value.is_a?(Numeric)
651
+ end
652
+ end
653
+
654
+ def validate_value!(value)
655
+ case type
656
+ when :boolean
657
+ raise InvalidFeatureValueError, "Value must be boolean for type :boolean" unless [true, false].include?(value)
658
+ when :string
659
+ raise InvalidFeatureValueError, "Value must be a string for type :string" unless value.is_a?(String)
660
+ when :number
661
+ raise InvalidFeatureValueError, "Value must be numeric for type :number" unless value.is_a?(Numeric)
662
+ end
663
+ end
664
+ end
665
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class FeatureDependency
5
+ def self.check(feature_name, context = {})
6
+ feature = Magick.features[feature_name.to_s] || Magick[feature_name]
7
+ dependencies = feature.instance_variable_get(:@dependencies) || []
8
+
9
+ dependencies.all? do |dep_name|
10
+ Magick.enabled?(dep_name, context)
11
+ end
12
+ end
13
+
14
+ def self.add_dependency(feature_name, dependency_name)
15
+ feature = Magick.features[feature_name.to_s] || Magick[feature_name]
16
+ dependencies = feature.instance_variable_get(:@dependencies) || []
17
+ dependencies << dependency_name.to_s unless dependencies.include?(dependency_name.to_s)
18
+ feature.instance_variable_set(:@dependencies, dependencies)
19
+ end
20
+
21
+ def self.remove_dependency(feature_name, dependency_name)
22
+ feature = Magick.features[feature_name.to_s] || Magick[feature_name]
23
+ dependencies = feature.instance_variable_get(:@dependencies) || []
24
+ dependencies.delete(dependency_name.to_s)
25
+ feature.instance_variable_set(:@dependencies, dependencies)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class FeatureVariant
5
+ attr_reader :name, :value, :weight
6
+
7
+ def initialize(name, value, weight: 0)
8
+ @name = name.to_s
9
+ @value = value
10
+ @weight = weight.to_f
11
+ end
12
+
13
+ def to_h
14
+ { name: name, value: value, weight: weight }
15
+ end
16
+ end
17
+ end