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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +322 -0
- data/lib/disposita/configuration.rb +209 -0
- data/lib/disposita/error.rb +46 -0
- data/lib/disposita/formats/yaml.rb +67 -0
- data/lib/disposita/internal/deep_merge.rb +35 -0
- data/lib/disposita/internal/definition.rb +42 -0
- data/lib/disposita/internal/hash_tools.rb +100 -0
- data/lib/disposita/internal/schema_builder.rb +97 -0
- data/lib/disposita/internal/type_adapter.rb +70 -0
- data/lib/disposita/paths.rb +99 -0
- data/lib/disposita/schema.rb +312 -0
- data/lib/disposita/source.rb +79 -0
- data/lib/disposita/sources/environment.rb +55 -0
- data/lib/disposita/sources/file.rb +112 -0
- data/lib/disposita/sources/hash.rb +29 -0
- data/lib/disposita/types.rb +152 -0
- data/lib/disposita/version.rb +6 -0
- data/lib/disposita.rb +85 -0
- metadata +65 -0
|
@@ -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
|