confeature 0.0.0 → 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,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "json"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ require_relative "sdk_key"
10
+
11
+ module Confeature
12
+ module Internal
13
+ # HTTP layer for fetching `config_v6.json` from a Confeature CDN.
14
+ #
15
+ # Wraps stdlib `Net::HTTP` with:
16
+ # - User-Agent: `confeature-ruby-sdk/{VERSION}` (FR-012).
17
+ # - If-None-Match support for 304 reuse (FR-011).
18
+ # - Configurable request timeout (default 30 s, FR-007).
19
+ # - SDK key redaction in every log line (FR-013): the raw key is
20
+ # NEVER interpolated into messages; `SdkKey.redact` is the only
21
+ # path.
22
+ #
23
+ # `FetchResult.status` is one of:
24
+ # - :fetched — HTTP 200, fresh body + new ETag.
25
+ # - :not_modified — HTTP 304, caller keeps its cached body.
26
+ # - :failed — network error / non-2xx / timeout. `error`
27
+ # carries a FIXED, key-free reason code (#803 FR-002).
28
+ #
29
+ # The request URL carries the SDK key, as in every ConfigCat SDK —
30
+ # the `configcat` gem builds `<base>/configuration-files/<sdk_key>/
31
+ # config_v6.json` (configcat-8.0.1 `lib/configcat/configfetcher.rb:168`).
32
+ # So no exception text is ever passed on (it may quote the URL): only
33
+ # the exception's class picks a fixed reason code.
34
+ class ConfigFetcher
35
+ DEFAULT_BASE_URL = "https://cdn-global.confeature.io"
36
+
37
+ # Reason code when the key cannot be placed in a path segment
38
+ # ("", "." or "..") — the fetcher refuses to fetch (#803 FR-001).
39
+ INVALID_SDK_KEY = "INVALID_SDK_KEY"
40
+
41
+ # Reason code when the base URL cannot form an absolute http(s)
42
+ # URL — no request is made (#803 FR-002; sdk-go's code).
43
+ INVALID_REQUEST = "invalid_request"
44
+
45
+ # Transport failures -> fixed, key-free reason codes (#803 FR-002;
46
+ # mirrors sdk-go's `classifyError`). Order matters: a timeout is
47
+ # checked first (`Net::OpenTimeout` etc. descend from
48
+ # `Timeout::Error`).
49
+ TIMEOUT_ERRORS = [Timeout::Error].freeze
50
+ CONNECTION_RESET_ERRORS = [
51
+ EOFError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, IOError
52
+ ].freeze
53
+
54
+ # Bytes kept as-is in a path segment — Go's `url.PathEscape` set:
55
+ # unreserved chars plus `$ & + : = @`. Everything else
56
+ # (including `/`, `?`, `;`, `,`, `%`) is percent-encoded.
57
+ PATH_SEGMENT_UNSAFE = /[^A-Za-z0-9\-_.~$&+:=@]/n
58
+
59
+ FetchResult = Struct.new(:status, :body, :etag, :error, keyword_init: true)
60
+
61
+ attr_reader :url, :user_agent
62
+
63
+ def initialize(sdk_key:, version:, logger:, base_url: nil, request_timeout_seconds: 30.0)
64
+ raise ArgumentError, "request_timeout_seconds must be > 0" if request_timeout_seconds <= 0
65
+
66
+ @sdk_key = sdk_key
67
+ @logger = logger
68
+ @timeout = request_timeout_seconds
69
+ @user_agent = "confeature-ruby-sdk/#{version}"
70
+ # Every trailing "/" goes, so `<base>//configuration-files` never
71
+ # happens. The URL is NOT parsed here: a bad base URL must not
72
+ # raise from the constructor (a `URI::InvalidURIError` quotes the
73
+ # whole URL, key included). `fetch` parses it and maps a failure
74
+ # to the fixed `invalid_request` code (#803 FR-002).
75
+ @base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
76
+ @url = self.class.config_url(@base_url, sdk_key)
77
+ end
78
+
79
+ # The ONE function that builds the config URL (#803 FR-001) —
80
+ # ConfigCat's `<base>/configuration-files/<sdkKey>/config_v6.json`.
81
+ #
82
+ # - A key matching the shared shape (`sdk-key-shapes.json`) goes
83
+ # in RAW: split on `/`, each part path-segment-escaped (a no-op
84
+ # for a valid key), so `configcat-sdk-1/<22>/<22>` is three
85
+ # segments — the stock `configcat` gem's exact bytes.
86
+ # - Any other key is escaped into ONE segment (Go's
87
+ # `url.PathEscape`). When that segment is "", "." or "..", no
88
+ # escaper keeps it in place: returns nil and the fetcher refuses
89
+ # to fetch.
90
+ def self.config_url(base_url, sdk_key)
91
+ key = sdk_key.to_s
92
+ segments =
93
+ if SdkKey.valid_confeature_key_format?(key)
94
+ key.split("/").map { |part| escape_path_segment(part) }
95
+ else
96
+ segment = escape_path_segment(key)
97
+ return nil if ["", ".", ".."].include?(segment)
98
+
99
+ [segment]
100
+ end
101
+ "#{base_url}/configuration-files/#{segments.join("/")}/config_v6.json"
102
+ end
103
+
104
+ def self.escape_path_segment(part)
105
+ part.b.gsub(PATH_SEGMENT_UNSAFE) { |byte| format("%%%02X", byte.ord) }
106
+ end
107
+
108
+ # Reduce a transport / body-read exception to a FIXED, key-free
109
+ # reason code (#803 FR-002). The message is never read.
110
+ def self.classify_error(exc)
111
+ return "timeout" if TIMEOUT_ERRORS.any? { |klass| exc.is_a?(klass) }
112
+ return "connection_reset" if CONNECTION_RESET_ERRORS.any? { |klass| exc.is_a?(klass) }
113
+
114
+ "network_error"
115
+ end
116
+
117
+ # Fetch the config. Never raises — fail-closed.
118
+ def fetch(etag = nil)
119
+ return refuse_invalid_key if @url.nil?
120
+
121
+ uri = request_uri
122
+ return fail_with(INVALID_REQUEST) if uri.nil?
123
+
124
+ http = build_http(uri)
125
+ request = Net::HTTP::Get.new(uri.request_uri, build_headers(etag))
126
+ response = http.request(request)
127
+ parse(response)
128
+ rescue StandardError => e
129
+ handle_exception(e)
130
+ end
131
+
132
+ # No persistent connection in v1 (one connection per fetch keeps
133
+ # the implementation tiny and matches the synchronous polling
134
+ # semantics). Method retained for API symmetry with the other
135
+ # SDKs and to give future connection-pooling somewhere to land.
136
+ def close
137
+ # No-op in v1.
138
+ end
139
+
140
+ private
141
+
142
+ # The parsed request URL, or nil when the base URL cannot form an
143
+ # absolute http(s) URL. The `URI::Error` is dropped here, unread:
144
+ # its message quotes the whole URL, key included.
145
+ #
146
+ # The key path `config_url` builds never holds a raw `?` or `#` (a
147
+ # well-shaped key has none; `escape_path_segment` escapes them), so
148
+ # a query or fragment here can only come from a base URL holding
149
+ # `?` or `#` — which would move the key out of the path, into the
150
+ # query string (sent, and logged by proxies as a parameter) or the
151
+ # fragment (never sent). Such a base is refused: no request (as
152
+ # sdk-dart's `_parseTarget`).
153
+ def request_uri
154
+ uri = URI.parse(@url)
155
+ return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
156
+ return nil unless uri.query.nil? && uri.fragment.nil?
157
+
158
+ uri
159
+ rescue URI::Error
160
+ nil
161
+ end
162
+
163
+ def build_http(uri)
164
+ http = Net::HTTP.new(uri.host, uri.port)
165
+ http.use_ssl = uri.scheme == "https"
166
+ http.open_timeout = @timeout
167
+ http.read_timeout = @timeout
168
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
169
+ http
170
+ end
171
+
172
+ def build_headers(etag)
173
+ headers = {
174
+ "User-Agent" => @user_agent,
175
+ "Accept" => "application/json"
176
+ }
177
+ headers["If-None-Match"] = etag if etag && !etag.empty?
178
+ headers
179
+ end
180
+
181
+ def parse(response)
182
+ case response
183
+ when Net::HTTPNotModified
184
+ FetchResult.new(status: :not_modified, etag: response["etag"])
185
+ when Net::HTTPSuccess
186
+ parse_success(response)
187
+ else
188
+ reason = "HTTP #{response.code}"
189
+ @logger.error(
190
+ "ConfigFetcher: fetch failed for sdkKey=#{SdkKey.redact(@sdk_key)} reason=#{reason}"
191
+ )
192
+ FetchResult.new(status: :failed, error: reason)
193
+ end
194
+ end
195
+
196
+ def parse_success(response)
197
+ body = JSON.parse(response.body)
198
+ unless body.is_a?(Hash)
199
+ @logger.error(
200
+ "ConfigFetcher: fetch returned non-object JSON for " \
201
+ "sdkKey=#{SdkKey.redact(@sdk_key)}"
202
+ )
203
+ return FetchResult.new(status: :failed, error: "MALFORMED_JSON")
204
+ end
205
+
206
+ # Shape validation: a valid `config_v6.json` carries the
207
+ # `{p, f, s}` top-level shape. sdk-node / sdk-python / sdk-go
208
+ # tightened the same check so the corpus-wide
209
+ # `wire-cases.json::wrong-shape-200` case surfaces the same
210
+ # `parseFailed` everywhere (epic #146 / issue #148). A 200
211
+ # with a body that does not match the contract is a stronger
212
+ # signal of CDN drift than a 5xx and deserves its own typed
213
+ # error class. Probing the `f` key alone is sufficient — it
214
+ # is the only mandatory top-level key in the schema.
215
+ unless body.key?("f")
216
+ @logger.error(
217
+ "ConfigFetcher: fetch returned JSON without 'f' key for " \
218
+ "sdkKey=#{SdkKey.redact(@sdk_key)}"
219
+ )
220
+ return FetchResult.new(status: :failed, error: "MALFORMED_JSON")
221
+ end
222
+
223
+ FetchResult.new(status: :fetched, body: body, etag: response["etag"])
224
+ rescue JSON::ParserError => e
225
+ @logger.error(
226
+ "ConfigFetcher: fetch returned non-JSON body for " \
227
+ "sdkKey=#{SdkKey.redact(@sdk_key)}: #{e.class}"
228
+ )
229
+ FetchResult.new(status: :failed, error: "MALFORMED_JSON")
230
+ end
231
+
232
+ def refuse_invalid_key
233
+ @logger.error(
234
+ "ConfigFetcher: #{INVALID_SDK_KEY} for sdkKey=#{SdkKey.redact(@sdk_key)} " \
235
+ "— the key cannot form a request path; not fetching"
236
+ )
237
+ FetchResult.new(status: :failed, error: INVALID_SDK_KEY)
238
+ end
239
+
240
+ def handle_exception(exc)
241
+ # The URL holds the SDK key and exception messages may quote it:
242
+ # only the exception's class picks a fixed reason code.
243
+ fail_with(self.class.classify_error(exc))
244
+ end
245
+
246
+ def fail_with(reason)
247
+ @logger.error(
248
+ "ConfigFetcher: fetch failed for sdkKey=#{SdkKey.redact(@sdk_key)} reason=#{reason}"
249
+ )
250
+ FetchResult.new(status: :failed, error: reason)
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "time"
6
+ require_relative "comparators"
7
+ require_relative "prerequisites"
8
+ require_relative "segments"
9
+
10
+ module Confeature
11
+ module Internal
12
+ # ConfigCat-compatible evaluation-log trace builder (ADR 0003 / epic #494,
13
+ # Ruby slice of #501).
14
+ #
15
+ # Ruby port of the spec-117 TS reference
16
+ # (`packages/sdk-shared/src/eval-log.ts` `EvaluateLogBuilder`), the Python
17
+ # port (`apps/sdk-python/src/confeature_client/eval_log.py`), and the Go port
18
+ # (`apps/sdk-go/internal/evaluator/eval_log.go`). Produces the byte-identical
19
+ # indented textual trace ConfigCat's SDKs emit during flag evaluation — its
20
+ # `evaluationlog/` corpus (replayed via
21
+ # `packages/test-fixtures-sdk-parity/fixtures/parity-evaluationlog.json`) is
22
+ # the byte-oracle. This builder mirrors the TS class line-for-line so the
23
+ # federation ports stay consistent.
24
+ #
25
+ # The trace is emitted via the trace logger at INFO under event id 5000
26
+ # (ConfigCat's verbosity contract) — see `Evaluator.evaluate_traced`. The
27
+ # strings are ported VERBATIM from the canonical (Principle II).
28
+ module EvalLog
29
+ # ConfigCat's event id for the "config evaluated" INFO log line. Captured
30
+ # as `... [5000] ...`, matching the upstream oracle.
31
+ EVAL_LOG_EVENT_ID = "5000"
32
+
33
+ # ConfigCat indents each nested eval-log line by two spaces per level.
34
+ INDENT_UNIT = " "
35
+
36
+ # Placeholders ConfigCat substitutes for malformed condition parts (parity
37
+ # with configcat-common's EvaluateLogBuilder).
38
+ INVALID_VALUE_PLACEHOLDER = "<invalid value>"
39
+ INVALID_NAME_PLACEHOLDER = "<invalid name>"
40
+ INVALID_OPERATOR_PLACEHOLDER = "<invalid operator>"
41
+
42
+ # Max IS-ONE-OF / list items rendered before truncation (configcat parity).
43
+ STRING_LIST_MAX_LENGTH = 10
44
+
45
+ C = Comparators
46
+
47
+ # Per-comparator eval-log metadata (configcat-common parity), as
48
+ # `[token, kind, sensitive]` — `token` is the operator rendering, `kind`
49
+ # selects the condition value slot ("l"=list, "s"=string, "n"=number,
50
+ # "dt"=datetime), `sensitive` masks the value as `<hashed value>`. One
51
+ # table drives the condition rendering, mirroring eval-log.ts
52
+ # COMPARATOR_META.
53
+ COMPARATOR_META = {
54
+ C::TEXT_IS_ONE_OF => ["IS ONE OF", "l", false],
55
+ C::TEXT_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", false],
56
+ C::TEXT_CONTAINS_ANY_OF => ["CONTAINS ANY OF", "l", false],
57
+ C::TEXT_NOT_CONTAINS_ANY_OF => ["NOT CONTAINS ANY OF", "l", false],
58
+ C::SEMVER_IS_ONE_OF => ["IS ONE OF", "l", false],
59
+ C::SEMVER_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", false],
60
+ C::SEMVER_LESS => ["<", "s", false],
61
+ C::SEMVER_LESS_OR_EQUALS => ["<=", "s", false],
62
+ C::SEMVER_GREATER => [">", "s", false],
63
+ C::SEMVER_GREATER_OR_EQUALS => [">=", "s", false],
64
+ C::NUMBER_EQUALS => ["=", "n", false],
65
+ C::NUMBER_NOT_EQUALS => ["!=", "n", false],
66
+ C::NUMBER_LESS => ["<", "n", false],
67
+ C::NUMBER_LESS_OR_EQUALS => ["<=", "n", false],
68
+ C::NUMBER_GREATER => [">", "n", false],
69
+ C::NUMBER_GREATER_OR_EQUALS => [">=", "n", false],
70
+ C::SENSITIVE_TEXT_IS_ONE_OF => ["IS ONE OF", "l", true],
71
+ C::SENSITIVE_TEXT_IS_NOT_ONE_OF => ["IS NOT ONE OF", "l", true],
72
+ C::DATETIME_BEFORE => ["BEFORE", "dt", false],
73
+ C::DATETIME_AFTER => ["AFTER", "dt", false],
74
+ C::SENSITIVE_TEXT_EQUALS => ["EQUALS", "s", true],
75
+ C::SENSITIVE_TEXT_NOT_EQUALS => ["NOT EQUALS", "s", true],
76
+ C::SENSITIVE_TEXT_STARTS_WITH_ANY_OF => ["STARTS WITH ANY OF", "l", true],
77
+ C::SENSITIVE_TEXT_NOT_STARTS_WITH_ANY_OF => ["NOT STARTS WITH ANY OF", "l", true],
78
+ C::SENSITIVE_TEXT_ENDS_WITH_ANY_OF => ["ENDS WITH ANY OF", "l", true],
79
+ C::SENSITIVE_TEXT_NOT_ENDS_WITH_ANY_OF => ["NOT ENDS WITH ANY OF", "l", true],
80
+ C::SENSITIVE_ARRAY_CONTAINS_ANY_OF => ["ARRAY CONTAINS ANY OF", "l", true],
81
+ C::SENSITIVE_ARRAY_NOT_CONTAINS_ANY_OF => ["ARRAY NOT CONTAINS ANY OF", "l", true],
82
+ C::TEXT_EQUALS => ["EQUALS", "s", false],
83
+ C::TEXT_NOT_EQUALS => ["NOT EQUALS", "s", false],
84
+ C::TEXT_STARTS_WITH_ANY_OF => ["STARTS WITH ANY OF", "l", false],
85
+ C::TEXT_NOT_STARTS_WITH_ANY_OF => ["NOT STARTS WITH ANY OF", "l", false],
86
+ C::TEXT_ENDS_WITH_ANY_OF => ["ENDS WITH ANY OF", "l", false],
87
+ C::TEXT_NOT_ENDS_WITH_ANY_OF => ["NOT ENDS WITH ANY OF", "l", false],
88
+ C::ARRAY_CONTAINS_ANY_OF => ["ARRAY CONTAINS ANY OF", "l", false],
89
+ C::ARRAY_NOT_CONTAINS_ANY_OF => ["ARRAY NOT CONTAINS ANY OF", "l", false]
90
+ }.freeze
91
+
92
+ module_function
93
+
94
+ # Render a `UserComparator` as ConfigCat's eval-log operator token.
95
+ def format_user_comparator(comparator)
96
+ meta = COMPARATOR_META[comparator]
97
+ meta.nil? ? INVALID_OPERATOR_PLACEHOLDER : meta[0]
98
+ end
99
+
100
+ # Format a string list exactly as configcat-common's `formatStringList`:
101
+ # `'a', 'b'` joined by `, `, with an optional truncation appendix once
102
+ # `max_length` is exceeded.
103
+ def format_string_list(items, max_length)
104
+ return "" if items.empty?
105
+
106
+ appendix = ""
107
+ shown = items
108
+ if max_length.positive? && items.length > max_length
109
+ shown = items[0, max_length]
110
+ count = items.length - max_length
111
+ word = count == 1 ? "value" : "values"
112
+ appendix = ", ... <#{count} more #{word}>"
113
+ end
114
+ "'#{shown.join("', '")}'#{appendix}"
115
+ end
116
+
117
+ # Format a resolved SETTING value for the eval log (ConfigCat parity):
118
+ # boolean → `true`/`false` (lowercase), string → verbatim, number → its
119
+ # canonical decimal string. Mirrors the `'<value>'` token in ConfigCat's
120
+ # `Returning '<value>'.` footer (eval-log.ts `settingValueToString`).
121
+ def setting_value_to_log_string(value)
122
+ return value ? "true" : "false" if [true, false].include?(value)
123
+ return value if value.is_a?(String)
124
+ return Comparators.number_to_canonical_string(value) if value.is_a?(Numeric)
125
+
126
+ ""
127
+ end
128
+
129
+ # Render a Unix-seconds value as ConfigCat's eval-log datetime suffix:
130
+ # JS `new Date(value*1000).toISOString()`, e.g. `2023-04-01T00:00:00.000Z`
131
+ # (always millisecond precision, UTC). Returns nil for non-finite values
132
+ # (NaN / ±Inf), matching the JS `isNaN(date)` guard.
133
+ def unix_seconds_to_iso(seconds)
134
+ f = seconds.to_f
135
+ return nil if f.nan? || f.infinite?
136
+
137
+ ms = (f * 1000).round
138
+ t = Time.at(ms / 1000, ms % 1000, :millisecond, in: "+00:00")
139
+ "#{t.strftime("%Y-%m-%dT%H:%M:%S")}.#{format("%03d", ms % 1000)}Z"
140
+ end
141
+
142
+ # Three-valued THEN consequence (MATCH / no match / error string). Mirrors
143
+ # TS's `boolean | string`. `kind` is `:true` / `:false` / `:error`;
144
+ # `err_text` carries the reason for the error case.
145
+ MatchOrError = Struct.new(:kind, :err_text)
146
+
147
+ def match_true
148
+ MatchOrError.new(:match, nil)
149
+ end
150
+
151
+ def match_false
152
+ MatchOrError.new(:no_match, nil)
153
+ end
154
+
155
+ def match_error(text)
156
+ MatchOrError.new(:errored, text)
157
+ end
158
+
159
+ # Ruby analogue of eval-log.ts `EvaluateLogBuilder`. indent/new_line/append
160
+ # primitives + the condition-rendering helpers. `eol` defaults to "\n" —
161
+ # the byte the upstream oracle uses.
162
+ class Builder
163
+ def initialize(eol: "\n")
164
+ @chunks = []
165
+ @indent_level = 0
166
+ @eol = eol
167
+ end
168
+
169
+ # Append text to the current line.
170
+ def append(text)
171
+ @chunks << text
172
+ self
173
+ end
174
+
175
+ # Break to a new line at the current indent, then optionally append text.
176
+ def new_line(text = nil)
177
+ @chunks << (@eol + (INDENT_UNIT * @indent_level))
178
+ @chunks << text unless text.nil?
179
+ self
180
+ end
181
+
182
+ def increase_indent
183
+ @indent_level += 1
184
+ self
185
+ end
186
+
187
+ def decrease_indent
188
+ @indent_level -= 1 if @indent_level.positive?
189
+ self
190
+ end
191
+
192
+ # ---- condition formatting --------------------------------------------
193
+
194
+ # Render one user condition (`User.<attr> <COMP> <value>`). The compact
195
+ # condition carries the value in `l` (list), `s` (string /
196
+ # sensitive-equals), or `d` (number / datetime), selected by the
197
+ # comparator kind.
198
+ def append_user_condition(condition)
199
+ attr = condition["a"].is_a?(String) ? condition["a"] : INVALID_NAME_PLACEHOLDER
200
+ cmp = condition["c"]
201
+ cmp = -1 unless cmp.is_a?(Integer) && ![true, false].include?(cmp)
202
+ meta = COMPARATOR_META[cmp]
203
+ return append_unknown_user_condition(attr, cmp, condition) if meta.nil?
204
+
205
+ _, kind, sensitive = meta
206
+ dispatch_user_condition(attr, cmp, kind, sensitive, condition)
207
+ end
208
+
209
+ # Append a single condition's boolean result (` => true` / ` => false…`).
210
+ def append_condition_consequence(result)
211
+ append(" => #{bool_to_log_string(result)}")
212
+ result ? self : append(", skipping the remaining AND conditions")
213
+ end
214
+
215
+ # Append a targeting rule's THEN consequence. `then_value` is the rule's
216
+ # formatted served value (e.g. `Dog`) for a fixed-value rule, or nil for
217
+ # a rule serving `% options`. `match` is MATCH / no match / an error.
218
+ def append_targeting_rule_consequence(then_value, match, newline)
219
+ increase_indent
220
+ # `newline` (the param) must NOT shadow the `new_line` builder method:
221
+ # break to a fresh line when the rule had conditions, else a single
222
+ # space before `THEN` (mirrors eval-log.ts appendTargetingRuleConsequence).
223
+ if newline
224
+ new_line
225
+ else
226
+ append(" ")
227
+ end
228
+ append("THEN")
229
+ append(then_value.nil? ? " % options" : " '#{then_value}'")
230
+ append(" => ")
231
+ case match.kind
232
+ when :match then append("MATCH, applying rule")
233
+ when :no_match then append("no match")
234
+ else append(match.err_text)
235
+ end
236
+ decrease_indent
237
+ end
238
+
239
+ # Append a prerequisite-flag condition (`Flag 'X' EQUALS 'Y'`).
240
+ def append_prerequisite_flag_condition(prereq_key, comparator, comparison_value)
241
+ op = comparator == Prerequisites::PREREQUISITE_NOT_EQUALS ? "NOT EQUALS" : "EQUALS"
242
+ append("Flag '#{prereq_key}' #{op} '#{comparison_value}'")
243
+ end
244
+
245
+ # Append a segment condition (`User IS [NOT] IN SEGMENT 'X'`).
246
+ def append_segment_condition(segment_name, comparator)
247
+ op = comparator == Segments::SEGMENT_IS_NOT_IN ? "IS NOT IN SEGMENT" : "IS IN SEGMENT"
248
+ append("User #{op} '#{segment_name}'")
249
+ end
250
+
251
+ # Append a bare boolean condition result (`true` / `false`).
252
+ def append_condition_result(result)
253
+ append(bool_to_log_string(result))
254
+ end
255
+
256
+ def to_s
257
+ @chunks.join
258
+ end
259
+
260
+ private
261
+
262
+ def dispatch_user_condition(attr, cmp, kind, sensitive, condition)
263
+ case kind
264
+ when "l"
265
+ lv = condition["l"]
266
+ append_user_condition_string_list(attr, cmp, lv.is_a?(Array) ? lv : nil, sensitive)
267
+ when "s"
268
+ append_user_condition_string(attr, cmp, condition["s"], sensitive)
269
+ when "n"
270
+ dv = condition["d"]
271
+ append_user_condition_number(attr, cmp, dv&.to_f, false)
272
+ else # "dt"
273
+ dv = condition["d"]
274
+ append_user_condition_number(attr, cmp, dv&.to_f, true)
275
+ end
276
+ end
277
+
278
+ # Unknown comparator: fall back to the first present value slot.
279
+ def append_unknown_user_condition(attr, cmp, condition)
280
+ sv = condition["s"]
281
+ return append_user_condition_core(attr, cmp, sv) if sv.is_a?(String)
282
+
283
+ dv = condition["d"]
284
+ return append_user_condition_core(attr, cmp, Comparators.number_to_canonical_string(dv.to_f)) unless dv.nil?
285
+
286
+ lv = condition["l"]
287
+ return append_user_condition_core(attr, cmp, lv[0].to_s) if lv.is_a?(Array) && !lv.empty?
288
+
289
+ append_user_condition_core(attr, cmp, "")
290
+ end
291
+
292
+ def append_user_condition_core(attr, comparator, value)
293
+ value = INVALID_VALUE_PLACEHOLDER if value == ""
294
+ append("User.#{attr} #{EvalLog.format_user_comparator(comparator)} '#{value}'")
295
+ end
296
+
297
+ def append_user_condition_string(attr, comparator, value, is_sensitive)
298
+ return append_user_condition_core(attr, comparator, "") unless value.is_a?(String)
299
+ return append_user_condition_core(attr, comparator, "<hashed value>") if is_sensitive
300
+
301
+ append_user_condition_core(attr, comparator, value)
302
+ end
303
+
304
+ def append_user_condition_string_list(attr, comparator, values, is_sensitive)
305
+ return append_user_condition_core(attr, comparator, "") unless values.is_a?(Array)
306
+
307
+ comparator_text = EvalLog.format_user_comparator(comparator)
308
+ if is_sensitive
309
+ word = values.length == 1 ? "value" : "values"
310
+ return append("User.#{attr} #{comparator_text} [<#{values.length} hashed #{word}>]")
311
+ end
312
+ list_text = EvalLog.format_string_list(values, STRING_LIST_MAX_LENGTH)
313
+ append("User.#{attr} #{comparator_text} [#{list_text}]")
314
+ end
315
+
316
+ def append_user_condition_number(attr, comparator, value, is_datetime)
317
+ return append_user_condition_core(attr, comparator, "") if value.nil?
318
+
319
+ comparator_text = EvalLog.format_user_comparator(comparator)
320
+ value_text = Comparators.number_to_canonical_string(value)
321
+ if is_datetime
322
+ iso = EvalLog.unix_seconds_to_iso(value)
323
+ return append("User.#{attr} #{comparator_text} '#{value_text}' (#{iso} UTC)") unless iso.nil?
324
+ end
325
+ append("User.#{attr} #{comparator_text} '#{value_text}'")
326
+ end
327
+
328
+ def bool_to_log_string(value)
329
+ value ? "true" : "false"
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end