toggly 0.1.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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for percentage-based rollouts.
6
+ #
7
+ # Uses FNV-1a hash for consistent bucketing based on
8
+ # feature key and user identity.
9
+ class Percentage < Base
10
+ # FNV-1a hash constants (32-bit)
11
+ FNV_PRIME = 0x01000193
12
+ FNV_OFFSET_BASIS = 0x811c9dc5
13
+
14
+ def self.type
15
+ "percentage"
16
+ end
17
+
18
+ # Evaluate percentage rollout
19
+ #
20
+ # @param rule [Hash] Rule with "percentage" or "value" key (0-100)
21
+ # @param context [Context] Evaluation context with identity
22
+ # @param feature_key [String] The feature key
23
+ # @return [Boolean] True if user falls within percentage
24
+ def evaluate(rule, context, feature_key: nil)
25
+ percentage = rule_value(rule, "percentage") ||
26
+ rule_value(rule, "value") ||
27
+ 0
28
+
29
+ percentage = percentage.to_f
30
+
31
+ # 0% always off, 100% always on
32
+ return false if percentage <= 0
33
+ return true if percentage >= 100
34
+
35
+ # Need identity for percentage rollouts
36
+ return false unless context&.identity?
37
+
38
+ # Calculate bucket using FNV-1a hash
39
+ bucket_key = "#{feature_key}:#{context.identity}"
40
+ bucket = calculate_bucket(bucket_key)
41
+
42
+ bucket < percentage
43
+ end
44
+
45
+ private
46
+
47
+ # Calculate bucket (0-100) using FNV-1a hash
48
+ #
49
+ # @param key [String] The key to hash
50
+ # @return [Float] Bucket value 0-100
51
+ def calculate_bucket(key)
52
+ hash = fnv1a_hash(key)
53
+ (hash % 10_000) / 100.0
54
+ end
55
+
56
+ # FNV-1a hash implementation
57
+ #
58
+ # @param data [String] Data to hash
59
+ # @return [Integer] 32-bit hash value
60
+ def fnv1a_hash(data)
61
+ hash = FNV_OFFSET_BASIS
62
+
63
+ data.each_byte do |byte|
64
+ hash ^= byte
65
+ hash = (hash * FNV_PRIME) & 0xFFFFFFFF
66
+ end
67
+
68
+ hash
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for user/group targeting rules.
6
+ #
7
+ # Supports targeting by:
8
+ # - Specific user identities
9
+ # - Group membership
10
+ class Targeting < Base
11
+ def self.type
12
+ "targeting"
13
+ end
14
+
15
+ # Evaluate targeting rule
16
+ #
17
+ # @param rule [Hash] Rule with "users" and/or "groups" arrays
18
+ # @param context [Context] Evaluation context
19
+ # @param feature_key [String] The feature key
20
+ # @return [Boolean, nil] True if matched, false if excluded, nil to continue
21
+ def evaluate(rule, context, feature_key: nil)
22
+ return nil unless context
23
+
24
+ # Check user targeting
25
+ users = Array(rule_value(rule, "users"))
26
+ excluded_users = Array(rule_value(rule, "excludedUsers") || rule_value(rule, "excluded_users"))
27
+
28
+ if context.identity?
29
+ # Check exclusion first
30
+ return false if excluded_users.any? { |u| u.to_s == context.identity }
31
+
32
+ # Check inclusion
33
+ return true if users.any? { |u| u.to_s == context.identity }
34
+ end
35
+
36
+ # Check group targeting
37
+ groups = Array(rule_value(rule, "groups"))
38
+ excluded_groups = Array(rule_value(rule, "excludedGroups") || rule_value(rule, "excluded_groups"))
39
+
40
+ # Check group exclusion
41
+ return false if excluded_groups.any? { |g| context.in_group?(g) }
42
+
43
+ # Check group inclusion
44
+ return true if groups.any? { |g| context.in_group?(g) }
45
+
46
+ # No match, continue evaluation
47
+ nil
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for time-based feature windows.
6
+ #
7
+ # Enables features within a specific time range.
8
+ class TimeWindow < Base
9
+ def self.type
10
+ "time_window"
11
+ end
12
+
13
+ # Evaluate time window rule
14
+ #
15
+ # @param rule [Hash] Rule with "startTime"/"start_time" and/or "endTime"/"end_time"
16
+ # @param context [Context] Evaluation context (ignored)
17
+ # @param feature_key [String] The feature key
18
+ # @return [Boolean] True if current time is within window
19
+ def evaluate(rule, _context, feature_key: nil)
20
+ now = Time.now.utc
21
+
22
+ start_time = parse_time(
23
+ rule_value(rule, "startTime") || rule_value(rule, "start_time")
24
+ )
25
+ end_time = parse_time(
26
+ rule_value(rule, "endTime") || rule_value(rule, "end_time")
27
+ )
28
+
29
+ # No time constraints means always valid
30
+ return true if start_time.nil? && end_time.nil?
31
+
32
+ # Check start time
33
+ return false if start_time && now < start_time
34
+
35
+ # Check end time
36
+ return false if end_time && now > end_time
37
+
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def parse_time(value)
44
+ return nil if value.nil?
45
+ return value if value.is_a?(Time)
46
+
47
+ Time.parse(value.to_s).utc
48
+ rescue ArgumentError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Represents a feature flag definition.
5
+ #
6
+ # @example
7
+ # definition = Toggly::FeatureDefinition.new(
8
+ # feature_key: "dark-mode",
9
+ # feature_type: "Release",
10
+ # enabled: true,
11
+ # rules: [{ "type" => "percentage", "value" => 50 }]
12
+ # )
13
+ class FeatureDefinition
14
+ # @return [String] Unique feature key
15
+ attr_reader :feature_key
16
+
17
+ # @return [String] Feature type (Release, Experiment, Ops, Permission)
18
+ attr_reader :feature_type
19
+
20
+ # @return [Boolean] Whether the feature is globally enabled
21
+ attr_reader :enabled
22
+
23
+ # @return [Array<Hash>] Evaluation rules
24
+ attr_reader :rules
25
+
26
+ # @return [Hash] Additional metadata
27
+ attr_reader :metadata
28
+
29
+ # @return [Time, nil] When the feature was created
30
+ attr_reader :created_at
31
+
32
+ # @return [Time, nil] When the feature was last updated
33
+ attr_reader :updated_at
34
+
35
+ # @return [String, nil] Feature description
36
+ attr_reader :description
37
+
38
+ # Feature types
39
+ TYPES = %w[Release Experiment Ops Permission].freeze
40
+
41
+ def initialize(
42
+ feature_key:,
43
+ feature_type: "Release",
44
+ enabled: false,
45
+ rules: [],
46
+ metadata: {},
47
+ description: nil,
48
+ created_at: nil,
49
+ updated_at: nil
50
+ )
51
+ @feature_key = feature_key.to_s
52
+ @feature_type = validate_type(feature_type)
53
+ @enabled = enabled ? true : false
54
+ @rules = Array(rules)
55
+ @metadata = metadata || {}
56
+ @description = description
57
+ @created_at = parse_time(created_at)
58
+ @updated_at = parse_time(updated_at)
59
+ end
60
+
61
+ # Create from a hash (e.g., from JSON)
62
+ #
63
+ # @param hash [Hash] Feature definition hash
64
+ # @return [FeatureDefinition]
65
+ def self.from_hash(hash)
66
+ hash = symbolize_keys(hash)
67
+
68
+ # Map API "filters" to internal "rules" format
69
+ rules = hash[:rules] || hash[:filters] || []
70
+
71
+ # In Toggly's model, features are enabled based on filter evaluation.
72
+ # If the API doesn't send "enabled", derive it from whether filters exist.
73
+ enabled = if hash.key?(:enabled)
74
+ hash[:enabled]
75
+ else
76
+ !rules.empty?
77
+ end
78
+
79
+ new(
80
+ feature_key: hash[:featureKey] || hash[:feature_key],
81
+ feature_type: hash[:featureType] || hash[:feature_type] || "Release",
82
+ enabled: enabled,
83
+ rules: rules,
84
+ metadata: hash[:metadata] || {},
85
+ description: hash[:description],
86
+ created_at: hash[:createdAt] || hash[:created_at],
87
+ updated_at: hash[:updatedAt] || hash[:updated_at]
88
+ )
89
+ end
90
+
91
+ # Convert to hash for serialization
92
+ #
93
+ # @return [Hash]
94
+ def to_h
95
+ {
96
+ feature_key: @feature_key,
97
+ feature_type: @feature_type,
98
+ enabled: @enabled,
99
+ rules: @rules,
100
+ metadata: @metadata,
101
+ description: @description,
102
+ created_at: @created_at&.iso8601,
103
+ updated_at: @updated_at&.iso8601
104
+ }
105
+ end
106
+
107
+ # Check if feature has rules
108
+ #
109
+ # @return [Boolean]
110
+ def rules?
111
+ !@rules.empty?
112
+ end
113
+
114
+ # Check if feature is a release toggle
115
+ #
116
+ # @return [Boolean]
117
+ def release?
118
+ @feature_type == "Release"
119
+ end
120
+
121
+ # Check if feature is an experiment
122
+ #
123
+ # @return [Boolean]
124
+ def experiment?
125
+ @feature_type == "Experiment"
126
+ end
127
+
128
+ # Check if feature is an ops toggle
129
+ #
130
+ # @return [Boolean]
131
+ def ops?
132
+ @feature_type == "Ops"
133
+ end
134
+
135
+ # Check if feature is a permission toggle
136
+ #
137
+ # @return [Boolean]
138
+ def permission?
139
+ @feature_type == "Permission"
140
+ end
141
+
142
+ # Equality check
143
+ #
144
+ # @param other [FeatureDefinition]
145
+ # @return [Boolean]
146
+ def ==(other)
147
+ return false unless other.is_a?(FeatureDefinition)
148
+
149
+ @feature_key == other.feature_key &&
150
+ @feature_type == other.feature_type &&
151
+ @enabled == other.enabled &&
152
+ @rules == other.rules
153
+ end
154
+ alias eql? ==
155
+
156
+ # Hash code for use in collections
157
+ #
158
+ # @return [Integer]
159
+ def hash
160
+ [@feature_key, @feature_type, @enabled, @rules].hash
161
+ end
162
+
163
+ private
164
+
165
+ def validate_type(type)
166
+ type_str = type.to_s
167
+ return type_str if TYPES.include?(type_str)
168
+
169
+ "Release"
170
+ end
171
+
172
+ def parse_time(value)
173
+ return nil if value.nil?
174
+ return value if value.is_a?(Time)
175
+
176
+ Time.parse(value.to_s)
177
+ rescue ArgumentError
178
+ nil
179
+ end
180
+
181
+ def self.symbolize_keys(hash)
182
+ return hash unless hash.is_a?(Hash)
183
+
184
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
185
+ end
186
+ private_class_method :symbolize_keys
187
+ end
188
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Registry for evaluators.
5
+ #
6
+ # Manages the collection of evaluators and dispatches
7
+ # evaluation requests to the appropriate handler.
8
+ class Registry
9
+ def initialize
10
+ @evaluators = {}
11
+ @mutex = Mutex.new
12
+ register_defaults
13
+ end
14
+
15
+ # Register an evaluator
16
+ #
17
+ # @param evaluator [Evaluators::Base] The evaluator instance
18
+ # @return [self]
19
+ def register(evaluator)
20
+ @mutex.synchronize do
21
+ @evaluators[evaluator.class.type.to_s.downcase] = evaluator
22
+ end
23
+ self
24
+ end
25
+
26
+ # Get an evaluator by type
27
+ #
28
+ # @param type [String] The evaluator type
29
+ # @return [Evaluators::Base, nil]
30
+ def get(type)
31
+ @mutex.synchronize do
32
+ @evaluators[type.to_s.downcase]
33
+ end
34
+ end
35
+
36
+ # Check if an evaluator exists for a type
37
+ #
38
+ # @param type [String] The evaluator type
39
+ # @return [Boolean]
40
+ def registered?(type)
41
+ @mutex.synchronize do
42
+ @evaluators.key?(type.to_s.downcase)
43
+ end
44
+ end
45
+
46
+ # List all registered evaluator types
47
+ #
48
+ # @return [Array<String>]
49
+ def types
50
+ @mutex.synchronize do
51
+ @evaluators.keys
52
+ end
53
+ end
54
+
55
+ # Unregister an evaluator
56
+ #
57
+ # @param type [String] The evaluator type
58
+ # @return [Evaluators::Base, nil] The removed evaluator
59
+ def unregister(type)
60
+ @mutex.synchronize do
61
+ @evaluators.delete(type.to_s.downcase)
62
+ end
63
+ end
64
+
65
+ # Clear all evaluators
66
+ #
67
+ # @return [self]
68
+ def clear
69
+ @mutex.synchronize do
70
+ @evaluators.clear
71
+ end
72
+ self
73
+ end
74
+
75
+ private
76
+
77
+ def register_defaults
78
+ register(Evaluators::AlwaysOn.new)
79
+ register(Evaluators::AlwaysOff.new)
80
+ register(Evaluators::Percentage.new)
81
+ register(Evaluators::Targeting.new)
82
+ register(Evaluators::TimeWindow.new)
83
+ register(Evaluators::ContextualTargeting.new)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module SnapshotProviders
5
+ # Base class for snapshot providers.
6
+ #
7
+ # Snapshot providers persist feature definitions for
8
+ # offline access and faster startup.
9
+ class Base
10
+ # Save definitions snapshot
11
+ #
12
+ # @param definitions [Hash<String, FeatureDefinition>] Definitions to save
13
+ # @param metadata [Hash] Optional metadata (e.g., version, timestamp)
14
+ # @raise [NotImplementedError]
15
+ def save(definitions, metadata = {})
16
+ raise NotImplementedError, "Subclass must implement #save"
17
+ end
18
+
19
+ # Load definitions snapshot
20
+ #
21
+ # @return [Hash, nil] Hash with :definitions and :metadata, or nil if not found
22
+ # @raise [NotImplementedError]
23
+ def load
24
+ raise NotImplementedError, "Subclass must implement #load"
25
+ end
26
+
27
+ # Clear the snapshot
28
+ #
29
+ # @raise [NotImplementedError]
30
+ def clear
31
+ raise NotImplementedError, "Subclass must implement #clear"
32
+ end
33
+
34
+ # Check if a snapshot exists
35
+ #
36
+ # @return [Boolean]
37
+ def exists?
38
+ !load.nil?
39
+ rescue StandardError
40
+ false
41
+ end
42
+
43
+ protected
44
+
45
+ # Serialize definitions to a storable format
46
+ #
47
+ # @param definitions [Hash<String, FeatureDefinition>] Definitions
48
+ # @return [Array<Hash>]
49
+ def serialize_definitions(definitions)
50
+ definitions.values.map(&:to_h)
51
+ end
52
+
53
+ # Deserialize definitions from stored format
54
+ #
55
+ # @param data [Array<Hash>] Serialized definitions
56
+ # @return [Hash<String, FeatureDefinition>]
57
+ def deserialize_definitions(data)
58
+ return {} unless data.is_a?(Array)
59
+
60
+ data.each_with_object({}) do |item, hash|
61
+ definition = FeatureDefinition.from_hash(item)
62
+ hash[definition.feature_key] = definition
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Toggly
7
+ module SnapshotProviders
8
+ # File-based snapshot provider.
9
+ #
10
+ # Persists feature definitions to a JSON file.
11
+ class File < Base
12
+ # @return [String] Path to the snapshot file
13
+ attr_reader :path
14
+
15
+ # @param path [String] Path to the snapshot file
16
+ def initialize(path:)
17
+ super()
18
+ @path = path
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Save definitions to file
23
+ #
24
+ # @param definitions [Hash<String, FeatureDefinition>] Definitions
25
+ # @param metadata [Hash] Optional metadata
26
+ def save(definitions, metadata = {})
27
+ @mutex.synchronize do
28
+ ensure_directory_exists
29
+
30
+ data = {
31
+ "definitions" => serialize_definitions(definitions),
32
+ "metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
33
+ }
34
+
35
+ # Write to temp file first, then rename (atomic operation)
36
+ temp_path = "#{@path}.tmp"
37
+ ::File.write(temp_path, JSON.pretty_generate(data))
38
+ ::File.rename(temp_path, @path)
39
+ end
40
+ rescue StandardError => e
41
+ raise SnapshotError, "Failed to save snapshot: #{e.message}"
42
+ end
43
+
44
+ # Load definitions from file
45
+ #
46
+ # @return [Hash, nil] Hash with :definitions and :metadata
47
+ def load
48
+ @mutex.synchronize do
49
+ return nil unless ::File.exist?(@path)
50
+
51
+ content = ::File.read(@path)
52
+ data = JSON.parse(content)
53
+
54
+ {
55
+ definitions: deserialize_definitions(data["definitions"]),
56
+ metadata: symbolize_keys(data["metadata"] || {})
57
+ }
58
+ end
59
+ rescue JSON::ParserError => e
60
+ raise SnapshotError, "Failed to parse snapshot: #{e.message}"
61
+ rescue StandardError => e
62
+ raise SnapshotError, "Failed to load snapshot: #{e.message}"
63
+ end
64
+
65
+ # Clear the snapshot file
66
+ def clear
67
+ @mutex.synchronize do
68
+ FileUtils.rm_f(@path)
69
+ end
70
+ rescue StandardError => e
71
+ raise SnapshotError, "Failed to clear snapshot: #{e.message}"
72
+ end
73
+
74
+ # Check if snapshot file exists
75
+ #
76
+ # @return [Boolean]
77
+ def exists?
78
+ ::File.exist?(@path)
79
+ end
80
+
81
+ private
82
+
83
+ def ensure_directory_exists
84
+ dir = ::File.dirname(@path)
85
+ FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
86
+ end
87
+
88
+ def symbolize_keys(hash)
89
+ return {} unless hash.is_a?(Hash)
90
+
91
+ hash.transform_keys(&:to_sym)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module SnapshotProviders
5
+ # In-memory snapshot provider.
6
+ #
7
+ # Useful for testing or when persistence is not needed.
8
+ class Memory < Base
9
+ def initialize
10
+ super
11
+ @data = nil
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Save definitions to memory
16
+ #
17
+ # @param definitions [Hash<String, FeatureDefinition>] Definitions
18
+ # @param metadata [Hash] Optional metadata
19
+ def save(definitions, metadata = {})
20
+ @mutex.synchronize do
21
+ @data = {
22
+ definitions: serialize_definitions(definitions),
23
+ metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
24
+ }
25
+ end
26
+ end
27
+
28
+ # Load definitions from memory
29
+ #
30
+ # @return [Hash, nil] Hash with :definitions and :metadata
31
+ def load
32
+ @mutex.synchronize do
33
+ return nil unless @data
34
+
35
+ {
36
+ definitions: deserialize_definitions(@data[:definitions]),
37
+ metadata: @data[:metadata]
38
+ }
39
+ end
40
+ end
41
+
42
+ # Clear the snapshot
43
+ def clear
44
+ @mutex.synchronize do
45
+ @data = nil
46
+ end
47
+ end
48
+
49
+ # Check if snapshot exists
50
+ #
51
+ # @return [Boolean]
52
+ def exists?
53
+ @mutex.synchronize do
54
+ !@data.nil?
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ VERSION = "0.1.0"
5
+ end