dotlyte 0.1.1

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,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotlyte
4
+ # Schema rule for a single config key.
5
+ class SchemaRule
6
+ attr_accessor :type, :required, :format, :pattern, :enum_values,
7
+ :min, :max, :default_value, :sensitive, :doc
8
+
9
+ def initialize(**kwargs)
10
+ @type = kwargs[:type]
11
+ @required = kwargs.fetch(:required, false)
12
+ @format = kwargs[:format]
13
+ @pattern = kwargs[:pattern]
14
+ @enum_values = kwargs[:enum_values]
15
+ @min = kwargs[:min]
16
+ @max = kwargs[:max]
17
+ @default_value = kwargs[:default_value]
18
+ @sensitive = kwargs.fetch(:sensitive, false)
19
+ @doc = kwargs[:doc]
20
+ end
21
+ end
22
+
23
+ # A single schema violation.
24
+ class SchemaViolation
25
+ attr_reader :key, :message, :rule
26
+
27
+ def initialize(key:, message:, rule:)
28
+ @key = key
29
+ @message = message
30
+ @rule = rule
31
+ end
32
+
33
+ def to_s
34
+ "[#{rule}] #{key}: #{message}"
35
+ end
36
+ end
37
+
38
+ # Schema validation engine for DOTLYTE v2.
39
+ module Validator
40
+ FORMAT_PATTERNS = {
41
+ "email" => /\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/,
42
+ "uuid" => /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
43
+ "date" => /\A\d{4}-\d{2}-\d{2}\z/,
44
+ "ipv4" => /\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/
45
+ }.freeze
46
+
47
+ # Validate config data against a schema.
48
+ #
49
+ # @param data [Hash] the config data
50
+ # @param schema [Hash<String, SchemaRule>] the schema
51
+ # @param strict [Boolean] reject unknown keys
52
+ # @return [Array<SchemaViolation>]
53
+ def self.validate(data, schema, strict: false)
54
+ violations = []
55
+
56
+ schema.each do |key, rule|
57
+ val = get_nested(data, key)
58
+
59
+ if val.nil?
60
+ if rule.required
61
+ violations << SchemaViolation.new(
62
+ key: key, message: "required key '#{key}' is missing", rule: "required"
63
+ )
64
+ end
65
+ next
66
+ end
67
+
68
+ # Type check
69
+ if rule.type && !check_type(val, rule.type)
70
+ violations << SchemaViolation.new(
71
+ key: key,
72
+ message: "expected type '#{rule.type}', got #{val.class}",
73
+ rule: "type"
74
+ )
75
+ end
76
+
77
+ # Format check
78
+ if rule.format && val.is_a?(String) && !check_format(val, rule.format)
79
+ violations << SchemaViolation.new(
80
+ key: key,
81
+ message: "value '#{val}' does not match format '#{rule.format}'",
82
+ rule: "format"
83
+ )
84
+ end
85
+
86
+ # Pattern check
87
+ if rule.pattern && val.is_a?(String) && !val.match?(Regexp.new(rule.pattern))
88
+ violations << SchemaViolation.new(
89
+ key: key,
90
+ message: "value '#{val}' does not match pattern '#{rule.pattern}'",
91
+ rule: "pattern"
92
+ )
93
+ end
94
+
95
+ # Enum check
96
+ if rule.enum_values && !rule.enum_values.include?(val)
97
+ violations << SchemaViolation.new(
98
+ key: key,
99
+ message: "value #{val} not in allowed values: #{rule.enum_values}",
100
+ rule: "enum"
101
+ )
102
+ end
103
+
104
+ # Min/Max
105
+ if val.is_a?(Numeric)
106
+ if rule.min && val < rule.min
107
+ violations << SchemaViolation.new(
108
+ key: key, message: "value #{val} is less than minimum #{rule.min}", rule: "min"
109
+ )
110
+ end
111
+ if rule.max && val > rule.max
112
+ violations << SchemaViolation.new(
113
+ key: key, message: "value #{val} is greater than maximum #{rule.max}", rule: "max"
114
+ )
115
+ end
116
+ end
117
+ end
118
+
119
+ # Strict mode
120
+ if strict
121
+ flat_keys = flatten_keys(data)
122
+ flat_keys.each do |k|
123
+ unless schema.key?(k)
124
+ violations << SchemaViolation.new(
125
+ key: k, message: "unknown key '#{k}' (strict mode)", rule: "strict"
126
+ )
127
+ end
128
+ end
129
+ end
130
+
131
+ violations
132
+ end
133
+
134
+ # Apply schema defaults to data.
135
+ def self.apply_defaults(data, schema)
136
+ schema.each do |key, rule|
137
+ next if rule.default_value.nil?
138
+ next unless get_nested(data, key).nil?
139
+
140
+ set_nested(data, key, rule.default_value)
141
+ end
142
+ end
143
+
144
+ # Get all sensitive keys from schema.
145
+ def self.sensitive_keys(schema)
146
+ schema.select { |_, rule| rule.sensitive }.keys
147
+ end
148
+
149
+ # Assert valid — raises ValidationError on failure.
150
+ def self.assert_valid!(data, schema, strict: false)
151
+ violations = validate(data, schema, strict: strict)
152
+ return if violations.empty?
153
+
154
+ msg = "Schema validation failed:\n" +
155
+ violations.map { |v| " - #{v}" }.join("\n")
156
+ raise ValidationError.new(msg, violations: violations)
157
+ end
158
+
159
+ # Get a nested value via dot-notation key.
160
+ def self.get_nested(data, key)
161
+ parts = key.to_s.split(".")
162
+ current = data
163
+ parts.each do |part|
164
+ return nil unless current.is_a?(Hash)
165
+
166
+ current = current.key?(part) ? current[part] : current[part.to_sym]
167
+ return nil if current.nil?
168
+ end
169
+ current
170
+ end
171
+
172
+ # Set a nested value via dot-notation key.
173
+ def self.set_nested(data, key, value)
174
+ parts = key.to_s.split(".")
175
+ current = data
176
+ parts[0..-2].each do |part|
177
+ current[part] ||= {}
178
+ current[part] = {} unless current[part].is_a?(Hash)
179
+ current = current[part]
180
+ end
181
+ current[parts.last] = value
182
+ end
183
+
184
+ class << self
185
+ private
186
+
187
+ def check_type(val, expected)
188
+ case expected
189
+ when "string" then val.is_a?(String)
190
+ when "number" then val.is_a?(Numeric)
191
+ when "boolean" then [true, false].include?(val)
192
+ when "array" then val.is_a?(Array)
193
+ when "hash", "object" then val.is_a?(Hash)
194
+ else true
195
+ end
196
+ end
197
+
198
+ def check_format(val, fmt)
199
+ case fmt
200
+ when "url"
201
+ val.start_with?("http://") || val.start_with?("https://")
202
+ when "ip", "ipv4"
203
+ FORMAT_PATTERNS["ipv4"].match?(val)
204
+ when "port"
205
+ p = Integer(val, exception: false)
206
+ p && p >= 1 && p <= 65_535
207
+ when "email"
208
+ FORMAT_PATTERNS["email"].match?(val)
209
+ when "uuid"
210
+ FORMAT_PATTERNS["uuid"].match?(val)
211
+ when "date"
212
+ FORMAT_PATTERNS["date"].match?(val)
213
+ else
214
+ val.match?(Regexp.new(fmt))
215
+ end
216
+ end
217
+
218
+ def flatten_keys(data, prefix = "", out = [])
219
+ data.each do |key, value|
220
+ full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
221
+ if value.is_a?(Hash)
222
+ flatten_keys(value, full_key, out)
223
+ else
224
+ out << full_key
225
+ end
226
+ end
227
+ out
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotlyte
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Dotlyte
6
+ # Polling-based file watcher for DOTLYTE v2.
7
+ class ConfigWatcher
8
+ # Information about a detected change.
9
+ ChangeEvent = Struct.new(:path, :changed_keys, keyword_init: true)
10
+
11
+ # @param files [Array<String>] files to watch
12
+ # @param debounce_ms [Integer] polling interval in milliseconds
13
+ def initialize(files:, debounce_ms: 300)
14
+ @files = files
15
+ @interval = [debounce_ms, 100].max / 1000.0
16
+ @last_modified = {}
17
+ @on_change = nil
18
+ @key_watchers = {}
19
+ @on_error = nil
20
+ @previous_data = nil
21
+ @running = false
22
+ @thread = nil
23
+
24
+ @files.each do |f|
25
+ @last_modified[f] = File.mtime(f).to_f if File.exist?(f)
26
+ end
27
+ end
28
+
29
+ # Register general change callback.
30
+ def on_change(&block)
31
+ @on_change = block
32
+ end
33
+
34
+ # Watch a specific key for changes.
35
+ def watch_key(key, &block)
36
+ @key_watchers[key] = block
37
+ end
38
+
39
+ # Register error callback.
40
+ def on_error(&block)
41
+ @on_error = block
42
+ end
43
+
44
+ # Start watching with a reload proc.
45
+ #
46
+ # @param reload_fn [Proc] called to reload config, must return Hash
47
+ def start(reload_fn)
48
+ return if @running
49
+
50
+ @running = true
51
+ @thread = Thread.new do
52
+ while @running
53
+ poll(reload_fn)
54
+ sleep @interval
55
+ end
56
+ end
57
+ @thread.abort_on_exception = false
58
+ end
59
+
60
+ # Stop watching.
61
+ def stop
62
+ @running = false
63
+ @thread&.join(2)
64
+ @thread = nil
65
+ end
66
+
67
+ private
68
+
69
+ def poll(reload_fn)
70
+ changed_file = nil
71
+
72
+ @files.each do |f|
73
+ next unless File.exist?(f)
74
+
75
+ mtime = File.mtime(f).to_f
76
+ prev = @last_modified[f]
77
+ if prev.nil? || prev != mtime
78
+ @last_modified[f] = mtime
79
+ changed_file = f
80
+ break
81
+ end
82
+ end
83
+
84
+ return unless changed_file
85
+
86
+ new_data = reload_fn.call
87
+ return unless new_data
88
+
89
+ changed_keys = if @previous_data
90
+ diff_maps(@previous_data, new_data)
91
+ else
92
+ flatten_keys(new_data)
93
+ end
94
+
95
+ @on_change&.call(ChangeEvent.new(path: changed_file, changed_keys: changed_keys))
96
+
97
+ if @previous_data
98
+ changed_keys.each do |key|
99
+ watcher = @key_watchers[key]
100
+ next unless watcher
101
+
102
+ old_val = Validator.get_nested(@previous_data, key)
103
+ new_val = Validator.get_nested(new_data, key)
104
+ watcher.call(old_val, new_val)
105
+ end
106
+ end
107
+
108
+ @previous_data = new_data
109
+ rescue StandardError => e
110
+ @on_error&.call(e)
111
+ end
112
+
113
+ def diff_maps(old_map, new_map)
114
+ old_flat = flatten_map(old_map)
115
+ new_flat = flatten_map(new_map)
116
+ changed = Set.new
117
+
118
+ new_flat.each do |k, v|
119
+ changed.add(k) if old_flat[k] != v
120
+ end
121
+ old_flat.each_key do |k|
122
+ changed.add(k) unless new_flat.key?(k)
123
+ end
124
+
125
+ changed.to_a
126
+ end
127
+
128
+ def flatten_map(data, prefix = "", out = {})
129
+ data.each do |key, value|
130
+ full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
131
+ if value.is_a?(Hash)
132
+ flatten_map(value, full_key, out)
133
+ else
134
+ out[full_key] = value
135
+ end
136
+ end
137
+ out
138
+ end
139
+
140
+ def flatten_keys(data, prefix = "", out = [])
141
+ data.each do |key, value|
142
+ full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
143
+ if value.is_a?(Hash)
144
+ flatten_keys(value, full_key, out)
145
+ else
146
+ out << full_key
147
+ end
148
+ end
149
+ out
150
+ end
151
+ end
152
+ end
data/lib/dotlyte.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "dotlyte/version"
5
+ require "dotlyte/errors"
6
+ require "dotlyte/coercion"
7
+ require "dotlyte/merger"
8
+ require "dotlyte/interpolation"
9
+ require "dotlyte/validator"
10
+ require "dotlyte/encryption"
11
+ require "dotlyte/masking"
12
+ require "dotlyte/config"
13
+ require "dotlyte/loader"
14
+ require "dotlyte/watcher"
15
+
16
+ # DOTLYTE — The universal configuration library (v2).
17
+ #
18
+ # @example
19
+ # config = Dotlyte.load
20
+ # config.port # automatically Integer
21
+ # config.debug # automatically Boolean
22
+ # config.database.host # dot-notation access
23
+ #
24
+ # @example Advanced
25
+ # config = Dotlyte.load(
26
+ # env: "production",
27
+ # schema: { "port" => Dotlyte::SchemaRule.new(type: "number", required: true) },
28
+ # strict: true,
29
+ # find_up: true
30
+ # )
31
+ module Dotlyte
32
+ # Load configuration from all available sources.
33
+ #
34
+ # @param files [Array<String>, nil] Explicit files to load.
35
+ # @param prefix [String, nil] Environment variable prefix to strip.
36
+ # @param defaults [Hash, nil] Default values (lowest priority).
37
+ # @param sources [Array<String>, nil] Custom source order.
38
+ # @param env [String, nil] Environment name.
39
+ # @param schema [Hash<String, SchemaRule>, nil] Validation schema.
40
+ # @param strict [Boolean] Reject unknown keys.
41
+ # @param interpolate_vars [Boolean] Enable ${VAR} interpolation (default: true).
42
+ # @param overrides [Hash, nil] Override values (highest priority).
43
+ # @param debug [Boolean] Enable debug output.
44
+ # @param find_up [Boolean] Walk up directories to find config files.
45
+ # @param root_markers [Array<String>] Markers for root directory detection.
46
+ # @param cwd [String, nil] Working directory override.
47
+ # @param allow_all_env_vars [Boolean] Import all env vars without filtering.
48
+ # @param watch [Boolean] Watch files for changes.
49
+ # @param debounce_ms [Integer] Polling interval for watcher.
50
+ # @param custom_sources [Array<#load>] Custom source objects.
51
+ # @return [Config] Merged configuration object.
52
+ def self.load(**options)
53
+ Loader.new(**{
54
+ defaults: options[:defaults] || {}
55
+ }.merge(options)).load
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dotlyte
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - DOTLYTE Contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: One API to load .env, YAML, JSON, TOML, environment variables, and defaults
28
+ with automatic type coercion, layered priority, AES-256-GCM encryption, schema validation,
29
+ variable interpolation, sensitive value masking, and file watching.
30
+ email:
31
+ - hello@dotlyte.dev
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - README.md
37
+ - lib/dotlyte.rb
38
+ - lib/dotlyte/coercion.rb
39
+ - lib/dotlyte/config.rb
40
+ - lib/dotlyte/encryption.rb
41
+ - lib/dotlyte/errors.rb
42
+ - lib/dotlyte/interpolation.rb
43
+ - lib/dotlyte/loader.rb
44
+ - lib/dotlyte/masking.rb
45
+ - lib/dotlyte/merger.rb
46
+ - lib/dotlyte/validator.rb
47
+ - lib/dotlyte/version.rb
48
+ - lib/dotlyte/watcher.rb
49
+ homepage: https://dotlyte.dev
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://dotlyte.dev
54
+ source_code_uri: https://github.com/dotlyte-io/dotlyte/tree/main/langs/ruby
55
+ changelog_uri: https://github.com/dotlyte-io/dotlyte/blob/main/langs/ruby/CHANGELOG.md
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.0.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.0.3.1
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: The universal .env and configuration library with encryption, validation,
75
+ and more.
76
+ test_files: []