disposita 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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module Disposita
6
+ # Serialization codecs used by file sources.
7
+ # Supply a codec implementing load and dump through Sources::File.new.
8
+ module Formats
9
+ # Safe YAML codec used by the built-in file source.
10
+ #
11
+ # The codec deliberately accepts only ordinary data structures. Ruby object
12
+ # deserialization, Symbol tags and YAML aliases are disabled, preventing a
13
+ # configuration file from acting as an object-loading mechanism. Symbols in
14
+ # runtime configuration are serialized as plain strings and restored later
15
+ # by schema coercion when the declared type requires it.
16
+ #
17
+ # Consumers normally interact with this module indirectly through
18
+ # {Disposita::Sources::File}.
19
+ module YAML
20
+ module_function
21
+
22
+ # Decodes safe YAML into a configuration Hash.
23
+ #
24
+ # @param content [String] YAML document.
25
+ # @return [Hash] decoded mapping; an empty document becomes an empty Hash.
26
+ # @raise [Disposita::ParseError] if YAML is malformed, contains unsafe
27
+ # constructs, or has a non-mapping document root.
28
+ def load(content)
29
+ data = Psych.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false)
30
+ return {} if data.nil?
31
+ raise ParseError, "configuration root must be a mapping" unless data.is_a?(Hash)
32
+
33
+ data
34
+ rescue Psych::Exception => e
35
+ raise ParseError, "invalid YAML: #{e.message}"
36
+ end
37
+
38
+ # Encodes configuration data as safe, human-editable YAML.
39
+ #
40
+ # Hash keys and Symbols are converted to strings so emitted documents do
41
+ # not depend on Ruby-specific YAML tags.
42
+ #
43
+ # @param data [Hash] validated configuration payload.
44
+ # @return [String] YAML document.
45
+ def dump(data)
46
+ Psych.safe_dump(stringify(data), permitted_classes: [], permitted_symbols: [], aliases: false)
47
+ end
48
+
49
+ # Converts Ruby-oriented data to serialization-safe primitives.
50
+ #
51
+ # @param value [Object] nested configuration value.
52
+ # @return [Object] equivalent value containing string keys/symbols.
53
+ def stringify(value)
54
+ case value
55
+ when Hash
56
+ value.to_h { |key, child| [key.to_s, stringify(child)] }
57
+ when Array
58
+ value.map { |child| stringify(child) }
59
+ when Symbol
60
+ value.to_s
61
+ else
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ # Implementation details shared by the public configuration API.
5
+ #
6
+ # Consumers should use Schema, Configuration and Source instead of coupling
7
+ # integrations to these helpers, which may change without public API guarantees.
8
+ # @api private
9
+ module Internal
10
+ # Implements Disposita's default layer merge semantics.
11
+ #
12
+ # Nested hashes are merged recursively so namespaces can be overridden one
13
+ # setting at a time. Arrays and scalar values are replaced wholesale because
14
+ # concatenating them would introduce domain-specific semantics the schema did
15
+ # not explicitly request.
16
+ #
17
+ # @api private
18
+ module DeepMerge
19
+ module_function
20
+
21
+ # @param left [Hash] lower-precedence values.
22
+ # @param right [Hash] higher-precedence values.
23
+ # @return [Hash] merged representation.
24
+ def call(left, right)
25
+ left.merge(right) do |_key, old_value, new_value|
26
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
27
+ call(old_value, new_value)
28
+ else
29
+ new_value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Internal
5
+ # Sentinel distinguishing "no default declared" from an explicit nil default.
6
+ # @api private
7
+ UNDEFINED = Object.new.freeze
8
+
9
+ # Immutable internal record describing one leaf setting in a schema.
10
+ #
11
+ # DSL input is normalized into this value object so resolution, validation,
12
+ # introspection and persistence operate on one stable representation.
13
+ #
14
+ # @api private
15
+ SettingDefinition = Data.define(
16
+ :path,
17
+ :type,
18
+ :default,
19
+ :required,
20
+ :env,
21
+ :secret,
22
+ :description,
23
+ :coerce,
24
+ :validator
25
+ ) do
26
+ # @return [String] dotted setting path used in diagnostics.
27
+ def key = path.join(".")
28
+
29
+ # @return [Boolean] whether an explicit default was declared, including nil.
30
+ def default? = !default.equal?(UNDEFINED)
31
+
32
+ # @return [Boolean] whether diagnostics must redact this setting.
33
+ def secret? = secret
34
+
35
+ # @return [Boolean] whether the setting must exist after resolution.
36
+ def required? = required
37
+
38
+ # @return [Boolean] whether raw values may be coerced to the declared type.
39
+ def coerce? = coerce
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Internal
5
+ # Recursive Hash helpers shared by schema resolution and immutable output.
6
+ #
7
+ # These operations deliberately work only with ordinary Ruby containers and
8
+ # never use Marshal or object deserialization as a copying mechanism.
9
+ #
10
+ # @api private
11
+ module HashTools
12
+ module_function
13
+
14
+ # Normalizes nested mapping keys to Symbols, including mappings inside arrays.
15
+ # @param value [Object] raw tree.
16
+ # @return [Object] normalized tree.
17
+ def symbolize(value)
18
+ case value
19
+ when Hash
20
+ value.each_with_object({}) do |(key, child), result|
21
+ result[key.to_sym] = symbolize(child)
22
+ end
23
+ when Array
24
+ value.map { |child| symbolize(child) }
25
+ else
26
+ value
27
+ end
28
+ end
29
+
30
+ # Reads a nested path without confusing an absent value with nil.
31
+ # @param hash [Hash] tree to inspect.
32
+ # @param path [Array<Symbol>] leaf path.
33
+ # @return [Object] value or UNDEFINED when absent.
34
+ def get(hash, path)
35
+ path.reduce(hash) do |current, segment|
36
+ return UNDEFINED unless current.is_a?(Hash) && current.key?(segment)
37
+
38
+ current[segment]
39
+ end
40
+ end
41
+
42
+ # Assigns a leaf value, creating intermediate namespace hashes as needed.
43
+ # @param hash [Hash] tree to update.
44
+ # @param path [Array<Symbol>] non-empty leaf path.
45
+ # @param value [Object] value to assign.
46
+ # @return [Hash] updated tree.
47
+ def set(hash, path, value)
48
+ cursor = hash
49
+ path[0...-1].each { |segment| cursor = (cursor[segment] ||= {}) }
50
+ cursor[path.last] = value
51
+ hash
52
+ end
53
+
54
+ # Collects leaf paths for unknown-setting checks and provenance.
55
+ # @param hash [Hash] tree to traverse.
56
+ # @param prefix [Array<Symbol>] parent path.
57
+ # @param result [Array<Array<Symbol>>] accumulator.
58
+ # @return [Array<Array<Symbol>>] leaf paths.
59
+ def flatten_keys(hash, prefix = [], result = [])
60
+ hash.each do |key, value|
61
+ path = prefix + [key.to_sym]
62
+ value.is_a?(Hash) ? flatten_keys(value, path, result) : result << path
63
+ end
64
+ result
65
+ end
66
+
67
+ # Copies containers and values so exports do not mutate configuration.
68
+ # @param value [Object] tree to copy.
69
+ # @return [Object] detached copy, or original for non-duplicable values.
70
+ def deep_dup(value)
71
+ case value
72
+ when Hash
73
+ value.to_h { |key, child| [deep_dup(key), deep_dup(child)] }
74
+ when Array
75
+ value.map { |child| deep_dup(child) }
76
+ else
77
+ value.dup
78
+ end
79
+ rescue TypeError
80
+ value
81
+ end
82
+
83
+ # Freezes a tree recursively, including mapping keys and array members.
84
+ # @param value [Object] tree to freeze in place.
85
+ # @return [Object] frozen tree.
86
+ def deep_freeze(value)
87
+ case value
88
+ when Hash
89
+ value.each do |key, child|
90
+ deep_freeze(key)
91
+ deep_freeze(child)
92
+ end
93
+ when Array
94
+ value.each { |child| deep_freeze(child) }
95
+ end
96
+ value.freeze
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Internal
5
+ # Evaluates the schema declaration DSL and records setting definitions.
6
+ #
7
+ # The builder is intentionally private. Public callers keep the immutable
8
+ # {Disposita::Schema} returned by +Disposita.define+ rather than retaining
9
+ # mutable DSL state.
10
+ #
11
+ # @api private
12
+ class SchemaBuilder
13
+ # @return [Array<SettingDefinition>] definitions accumulated by this root
14
+ # builder and its nested namespace builders.
15
+ attr_reader :settings
16
+
17
+ # @param prefix [Array<Symbol>] namespace path for this builder.
18
+ # @param settings [Array<SettingDefinition>] shared definition collection.
19
+ def initialize(prefix: [], settings: [])
20
+ @prefix = prefix
21
+ @settings = settings
22
+ end
23
+
24
+ # Declares a nested namespace and evaluates its block in a child builder.
25
+ #
26
+ # @param name [String, Symbol] namespace segment.
27
+ # @yield nested schema DSL.
28
+ # @return [Object] result of evaluating the namespace block.
29
+ # @raise [SchemaError] when the name is invalid.
30
+ def namespace(name, &)
31
+ validate_name!(name)
32
+ self.class.new(prefix: @prefix + [name.to_sym], settings: settings).instance_eval(&)
33
+ end
34
+
35
+ # Declares one leaf configuration setting.
36
+ #
37
+ # The validator block belongs to the consumer's schema semantics; Disposita
38
+ # merely invokes it after type coercion. +optional+ currently exists to
39
+ # make intent explicit and to detect contradictory declarations; absence is
40
+ # otherwise optional unless +required: true+ is used.
41
+ #
42
+ # @param name [String, Symbol] leaf setting name.
43
+ # @param type [Object] Ruby class or type-like object understood by
44
+ # TypeAdapter.
45
+ # @param default [Object] default value, or UNDEFINED when absent.
46
+ # @param required [Boolean] whether resolution must produce the setting.
47
+ # @param optional [Boolean] explicit optional marker.
48
+ # @param env [String, nil] explicit environment variable name.
49
+ # @param secret [Boolean] whether diagnostics must redact the value.
50
+ # @param description [String, nil] user-facing documentation text.
51
+ # @param coerce [Boolean] whether raw values may be coerced.
52
+ # @yieldparam value [Object] coerced value for custom validation.
53
+ # @yieldreturn [Boolean] truthy when the value is valid.
54
+ # @raise [SchemaError] for contradictory or duplicate definitions.
55
+ def setting(name, type:, default: UNDEFINED, required: false, optional: false,
56
+ env: nil, secret: false, description: nil, coerce: true, &validator)
57
+ validate_name!(name)
58
+ validate_presence!(required, optional, default)
59
+ path = @prefix + [name.to_sym]
60
+ reject_duplicate!(path)
61
+
62
+ settings << SettingDefinition.new(
63
+ path: path.freeze,
64
+ type: type,
65
+ default: default,
66
+ required: required,
67
+ env: env,
68
+ secret: secret,
69
+ description: description,
70
+ coerce: coerce,
71
+ validator: validator
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def validate_presence!(required, optional, default)
78
+ raise SchemaError, "required and optional cannot both be true" if required && optional
79
+ return unless required && !default.equal?(UNDEFINED)
80
+
81
+ raise SchemaError, "required settings cannot declare a default"
82
+ end
83
+
84
+ def reject_duplicate!(path)
85
+ return unless settings.any? { |item| item.path == path }
86
+
87
+ raise SchemaError, "duplicate setting: #{path.join('.')}"
88
+ end
89
+
90
+ def validate_name!(name)
91
+ return if name.is_a?(String) || name.is_a?(Symbol)
92
+
93
+ raise SchemaError, "names must be String or Symbol"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Disposita
4
+ module Internal
5
+ # Bridges Ruby classes and type-like objects used by Disposita schemas.
6
+ #
7
+ # The adapter intentionally defines a very small protocol: a custom type may
8
+ # implement +valid?+ and optionally +coerce+. Otherwise normal Ruby case
9
+ # equality (===) is used for validation and a small set of primitive
10
+ # coercions is provided for configuration-friendly classes.
11
+ #
12
+ # Keeping this behind Internal lets Disposita later share or replace the type
13
+ # machinery without making Typio or another Rubcraft gem a hard dependency.
14
+ #
15
+ # @api private
16
+ module TypeAdapter
17
+ module_function
18
+
19
+ # @param type [Object] declared type.
20
+ # @param value [Object] candidate value.
21
+ # @return [Boolean] whether the value already satisfies the type.
22
+ def valid?(type, value)
23
+ return type.valid?(value) if type.respond_to?(:valid?)
24
+
25
+ type === value # rubocop:disable Style/CaseEquality -- Ruby case equality is the supported type protocol.
26
+ end
27
+
28
+ # Coerces a raw configuration value to a declared type.
29
+ #
30
+ # @param type [Object] declared type.
31
+ # @param value [Object] raw value.
32
+ # @return [Object] coerced value.
33
+ # @raise [CoercionError] when no safe conversion is available.
34
+ def coerce(type, value)
35
+ return value if valid?(type, value)
36
+ return type.coerce(value) if type.respond_to?(:coerce)
37
+
38
+ coerce_primitive(type, value)
39
+ rescue ArgumentError, TypeError => e
40
+ raise CoercionError, "cannot coerce #{value.inspect} to #{describe(type)}: #{e.message}"
41
+ end
42
+
43
+ # Converts built-in scalar types after custom protocols have been checked.
44
+ # @param type [Object] declared type.
45
+ # @param value [Object] raw value.
46
+ # @return [Object] converted scalar.
47
+ # @raise [ArgumentError, TypeError] when conversion is unavailable or invalid.
48
+ def coerce_primitive(type, value)
49
+ case type.respond_to?(:name) ? type.name : nil
50
+ when "String"
51
+ value.to_s
52
+ when "Integer"
53
+ Integer(value, 10)
54
+ when "Float"
55
+ Float(value)
56
+ when "Symbol"
57
+ value.is_a?(String) ? value.to_sym : raise(ArgumentError, "expected String")
58
+ else
59
+ raise ArgumentError, "no coercion available"
60
+ end
61
+ end
62
+
63
+ # @param type [Object] declared type.
64
+ # @return [String] human-readable type name for diagnostics.
65
+ def describe(type)
66
+ type.respond_to?(:name) && type.name ? type.name : type.inspect
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Disposita
6
+ # Cross-platform helpers for conventional configuration locations.
7
+ #
8
+ # Disposita owns only the mechanics of finding conventional user-level paths.
9
+ # The consumer still owns application names, filenames and project-local path
10
+ # conventions. In particular, Disposita never imposes a +.rubcraft+ or
11
+ # +.disposita+ directory on project repositories.
12
+ #
13
+ # User paths follow platform conventions: XDG on Linux/Unix when available,
14
+ # Application Support on macOS and APPDATA/LOCALAPPDATA on Windows.
15
+ module Paths
16
+ module_function
17
+
18
+ # Returns the conventional per-user configuration directory for an app.
19
+ #
20
+ # Supplying +env+ and +host_os+ explicitly makes the method deterministic in
21
+ # tests and lets embedding applications control which process environment
22
+ # is observed.
23
+ #
24
+ # @param application [String, Symbol] application/domain directory name.
25
+ # @param env [Hash] environment-like mapping used to find HOME/XDG/APPDATA.
26
+ # @param host_os [String] operating-system identifier, usually Ruby's
27
+ # +RbConfig::CONFIG["host_os"]+.
28
+ # @return [String] absolute or platform-native user configuration directory.
29
+ # @raise [Disposita::PathError] if the application name is empty or no base
30
+ # user configuration directory can be determined.
31
+ # @example
32
+ # Disposita::Paths.user_config("scm")
33
+ # # Linux: ~/.config/scm
34
+ # # macOS: ~/Library/Application Support/scm
35
+ # # Windows: %APPDATA%\\scm
36
+ def user_config(application, env: ENV, host_os: RbConfig::CONFIG["host_os"])
37
+ name = application.to_s
38
+ raise PathError, "application name cannot be empty" if name.empty?
39
+
40
+ base = config_home(env, host_os)
41
+
42
+ raise PathError, "cannot determine user configuration directory" unless base
43
+
44
+ ::File.join(base, name)
45
+ end
46
+
47
+ # Selects the platform base directory before appending a consumer name.
48
+ # @api private
49
+ # @param env [Hash] environment-like mapping.
50
+ # @param host_os [String] platform identifier.
51
+ # @return [String, nil] base directory when available.
52
+ def config_home(env, host_os)
53
+ return env["APPDATA"] || env["LOCALAPPDATA"] if windows?(host_os)
54
+ return home_path(env, "Library", "Application Support") if macos?(host_os)
55
+
56
+ env["XDG_CONFIG_HOME"] || home_path(env, ".config")
57
+ end
58
+
59
+ # Appends platform-specific directories to HOME when it is available.
60
+ # @api private
61
+ # @param env [Hash] environment-like mapping.
62
+ # @param segments [Array<String>] directory names.
63
+ # @return [String, nil] path under HOME.
64
+ def home_path(env, *segments)
65
+ ::File.join(env["HOME"], *segments) if env["HOME"]
66
+ end
67
+ private_class_method :config_home, :home_path
68
+
69
+ # Resolves a consumer-selected path inside a project root.
70
+ #
71
+ # The method protects against +..+ traversal escaping the supplied root. It
72
+ # does not choose the relative filename or directory; that policy belongs to
73
+ # the application using Disposita.
74
+ #
75
+ # @param root [String] project root.
76
+ # @param relative [String] relative path chosen by the consumer.
77
+ # @return [String] expanded path contained by +root+.
78
+ # @raise [Disposita::PathError] when the resolved path escapes +root+.
79
+ # @example
80
+ # Disposita::Paths.project(Dir.pwd, ".rubcraft/scm.yml")
81
+ def project(root, relative)
82
+ root = ::File.expand_path(root)
83
+ candidate = ::File.expand_path(relative, root)
84
+ unless candidate == root || candidate.start_with?("#{root}#{::File::SEPARATOR}")
85
+ raise PathError, "project configuration path escapes project root"
86
+ end
87
+
88
+ candidate
89
+ end
90
+
91
+ # @param host_os [String] Ruby host OS identifier.
92
+ # @return [Boolean] whether the identifier represents Windows.
93
+ def windows?(host_os) = host_os.match?(/mswin|mingw|cygwin/i)
94
+
95
+ # @param host_os [String] Ruby host OS identifier.
96
+ # @return [Boolean] whether the identifier represents macOS.
97
+ def macos?(host_os) = host_os.match?(/darwin/i)
98
+ end
99
+ end