All Files
(
100.0%
covered at
4.65
hits/line
)
15 files in total.
277 relevant lines,
277 lines covered and
0 lines missed.
(
100.0%
)
23 total branches,
23 branches covered and
0 branches missed.
(
100.0%
)
-
# frozen_string_literal: true
-
-
1
require "cogger"
-
1
require "zeitwerk"
-
-
1
Zeitwerk::Loader.new.then do |loader|
-
1
loader.push_dir __dir__
-
1
loader.inflector.inflect "json" => "JSON", "yaml" => "YAML"
-
1
loader.tag = File.basename __FILE__, ".rb"
-
1
loader.setup
-
end
-
-
# Main namespace.
-
1
module Etcher
-
1
LOGGER = Cogger.new id: :etcher
-
-
1
def self.loader registry = Zeitwerk::Registry
-
12
@loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") }
-
end
-
-
1
def self.new(...) = Builder.new(...)
-
-
1
def self.call(registry = Registry.new, **) = Resolver.new(registry).call(**)
-
end
-
# frozen_string_literal: true
-
-
1
require "core"
-
1
require "dry/monads"
-
1
require "refinements/hash"
-
-
1
module Etcher
-
# Builds a configuration.
-
1
class Builder
-
1
include Dry::Monads[:result]
-
-
1
using Refinements::Hash
-
-
1
def initialize registry = Registry.new
-
29
@registry = registry
-
29
freeze
-
end
-
-
1
def call(**overrides)
-
50
load.bind { |attributes| transform attributes }
-
20
.fmap { |attributes| attributes.merge! overrides.symbolize_keys! }
-
20
.bind { |attributes| validate attributes }
-
18
.bind { |attributes| model attributes }
-
end
-
-
1
private
-
-
1
attr_reader :registry
-
-
1
def load
-
26
registry.loaders
-
22
.map { |loader| loader.call.fmap { |pairs| pairs.flatten_keys.symbolize_keys! } }
-
12
.reduce(Success({})) { |all, result| merge all, result }
-
end
-
-
1
def transform attributes
-
24
registry.transformers.reduce Success(attributes) do |all, transformer|
-
8
merge all, transformer.call(attributes)
-
end
-
end
-
-
1
def validate attributes
-
20
registry.contract
-
.call(attributes)
-
.to_monad
-
.or do |result|
-
2
Failure step: __method__, constant: self.class, payload: result.errors.to_h
-
end
-
end
-
-
1
def model attributes
-
18
Success registry.model[**attributes.to_h].freeze
-
rescue ArgumentError => error
-
3
Failure step: __method__, constant: self.class, payload: "#{error.message.capitalize}."
-
end
-
-
1
def merge(*items)
-
20
in: 14
case items
-
14
else: 6
in Success(all), Success(subset) then Success(all.merge!(subset))
-
6
else items.last
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
-
1
module Etcher
-
# A simple passthrough contract.
-
1
Contract = lambda do |result|
-
19
else: 1
then: 18
def result.to_monad = Dry::Monads::Success self unless result.respond_to? :to_monad
-
19
result
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
-
# Finds internal constant if moniker matches, otherwise answers a failure.
-
1
module Etcher
-
1
include Dry::Monads[:result]
-
-
1
Finder = lambda do |namespace, moniker|
-
21
Etcher.const_get(namespace)
-
.constants
-
30
.find { |constant| constant.downcase == moniker }
-
.then do |constant|
-
20
then: 18
else: 2
return Dry::Monads::Success Etcher.const_get("#{namespace}::#{constant}") if constant
-
-
2
Dry::Monads::Failure "Unable to select #{moniker.inspect} within #{namespace.downcase}."
-
end
-
rescue NameError
-
1
Dry::Monads::Failure "Invalid namespace: #{namespace.inspect}."
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "core"
-
1
require "dry/monads"
-
-
1
module Etcher
-
1
module Loaders
-
# Loads environment configuration with optional includes.
-
1
class Environment
-
1
include Dry::Monads[:result]
-
-
1
def initialize attributes = ENV, only: Core::EMPTY_ARRAY
-
5
@attributes = attributes
-
5
@only = Array only
-
5
freeze
-
end
-
-
1
def call = Success attributes.slice(*only).transform_keys(&:downcase)
-
-
1
private
-
-
1
attr_reader :attributes, :only
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
-
1
module Etcher
-
1
module Loaders
-
# Loads (wraps) raw attributes.
-
1
class Hash
-
1
include Dry::Monads[:result]
-
-
1
def initialize(**attributes)
-
3
@attributes = attributes
-
3
freeze
-
end
-
-
1
def call = Success attributes
-
-
1
private
-
-
1
attr_reader :attributes
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "core"
-
1
require "dry/monads"
-
1
require "json"
-
-
1
module Etcher
-
1
module Loaders
-
# Loads a JSON configuration.
-
1
class JSON
-
1
include Dry::Monads[:result]
-
-
1
def initialize path, fallback: Core::EMPTY_HASH, logger: LOGGER
-
17
@path = path
-
17
@fallback = fallback
-
17
@logger = logger
-
17
freeze
-
end
-
-
1
def call
-
12
Success ::JSON.load_file(path)
-
2
rescue Errno::ENOENT, TypeError then debug_invalid_path
-
3
rescue ::JSON::ParserError => error then content_failure error
-
end
-
-
1
private
-
-
1
attr_reader :path, :fallback, :logger
-
-
1
def debug_invalid_path
-
4
logger.debug { "Invalid path: #{path_info}. Using fallback." }
-
2
Success fallback
-
end
-
-
1
def content_failure error
-
3
constant = self.class
-
3
token = error.message[/(?<token>'.+?')/, :token].to_s.tr "'", ""
-
-
3
then: 2
if token.empty?
-
2
Failure step: :load, constant:, payload: "File is empty: #{path_info}."
-
else: 1
else
-
1
Failure step: :load,
-
constant:,
-
payload: "Invalid content: #{token.inspect}. Path: #{path_info}."
-
end
-
end
-
-
1
def path_info = path.to_s.inspect
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "core"
-
1
require "dry/monads"
-
1
require "refinements/string"
-
1
require "yaml"
-
-
1
module Etcher
-
1
module Loaders
-
# Loads a YAML configuration.
-
1
class YAML
-
1
include Dry::Monads[:result]
-
-
1
using Refinements::String
-
-
1
def initialize path, fallback: Core::EMPTY_HASH, logger: LOGGER
-
14
@path = path
-
14
@fallback = fallback
-
14
@logger = logger
-
14
freeze
-
end
-
-
1
def call
-
13
load
-
2
rescue Errno::ENOENT, TypeError then debug_invalid_path
-
1
rescue Psych::AliasesNotEnabled then alias_failure
-
1
rescue Psych::DisallowedClass => error then disallowed_failure error
-
1
rescue Psych::SyntaxError => error then syntax_failure error
-
end
-
-
1
private
-
-
1
attr_reader :path, :fallback, :logger
-
-
1
def load
-
13
content = ::YAML.safe_load_file path
-
-
8
in: 5
case content
-
5
in: 2
in ::Hash then Success content
-
2
else: 1
in nil then empty_failure
-
1
else invalid_failure content
-
end
-
end
-
-
1
def debug_invalid_path
-
4
logger.debug { "Invalid path: #{path_info}. Using fallback." }
-
2
Success fallback
-
end
-
-
1
def empty_failure
-
2
Failure step: :load, constant: self.class, payload: "File is empty: #{path_info}."
-
end
-
-
1
def invalid_failure content
-
1
Failure step: :load,
-
constant: self.class,
-
payload: "Invalid content: #{content.inspect}. Path: #{path_info}."
-
end
-
-
1
def alias_failure
-
1
Failure step: :load,
-
constant: self.class,
-
payload: "Aliases are disabled, please remove. Path: #{path_info}."
-
end
-
-
1
def disallowed_failure error
-
1
Failure step: :load,
-
constant: self.class,
-
payload: "Invalid type, #{error.message.down}. Path: #{path_info}."
-
end
-
-
1
def syntax_failure error
-
1
Failure step: :load,
-
constant: self.class,
-
payload: "Invalid syntax, #{error.message[/found.+/]}. " \
-
"Path: #{path_info}."
-
end
-
-
1
def path_info = path.to_s.inspect
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module Etcher
-
# Provides a registry of customization for loading and resolving a configuration.
-
1
Registry = Data.define :contract, :model, :loaders, :transformers do
-
1
def self.find namespace, moniker, logger: LOGGER
-
19
in: 17
case Finder.call namespace, moniker
-
17
in: 1
in Success(constant) then constant
-
1
else: 1
in Failure(message) then logger.abort message
-
1
else logger.abort "Unable to find constant in registry."
-
end
-
end
-
-
1
def initialize contract: Contract, model: Hash, loaders: [], transformers: []
-
42
super
-
end
-
-
1
def add_loader(loader, ...) = add(loader, :Loaders, ...)
-
-
1
def remove_loader(index) = remove index, loaders
-
-
1
def add_transformer(transformer, ...) = add(transformer, :Transformers, ...)
-
-
1
def remove_transformer(index) = remove index, transformers
-
-
1
private
-
-
1
def add(item, namespace, ...)
-
26
collection = __send__ namespace.downcase
-
-
26
then: 16
if item.is_a? Symbol
-
32
self.class.find(namespace, item).then { |kind| collection.append kind.new(...) }
-
else: 10
else
-
10
collection.append item
-
end
-
-
26
self
-
end
-
-
1
def remove index, collection
-
4
collection.delete_at index
-
4
self
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
1
require "refinements/array"
-
-
1
module Etcher
-
# Builds and fully resolves a configuration.
-
1
class Resolver
-
1
include Dry::Monads[:result]
-
-
1
using Refinements::Array
-
-
1
def initialize registry = Registry.new, logger: LOGGER
-
10
@builder = Builder.new registry
-
10
@logger = logger
-
10
freeze
-
end
-
-
1
def call(**overrides)
-
9
in: 3
case builder.call(**overrides)
-
3
in Success(attributes) then attributes
-
in: 3
in Failure(step:, constant:, payload: String => payload)
-
3
logger.abort "#{step.capitalize} failure (#{constant}). #{payload}"
-
in: 1
in Failure(step:, constant:, payload: Hash => payload)
-
1
in: 1
log_and_abort step, constant, payload
-
1
else: 1
in Failure(String => message) then logger.abort message
-
1
else logger.abort "Unable to parse failure."
-
end
-
end
-
-
1
private
-
-
1
attr_reader :builder, :logger
-
-
1
def log_and_abort step, constant, errors
-
3
details = errors.map { |key, message| " - #{key} #{message.to_sentence}\n" }
-
.join
-
-
1
logger.abort "#{step.capitalize} failure (#{constant}). " \
-
"Unable to load configuration:\n#{details}"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
1
require "pathname"
-
1
require "refinements/hash"
-
-
1
module Etcher
-
1
module Transformers
-
# Conditionally updates value based on path.
-
1
class Basename
-
1
include Dry::Monads[:result]
-
-
1
using Refinements::Hash
-
-
1
def initialize key, fallback: Pathname.pwd.basename.to_s
-
4
@key = key
-
4
@fallback = fallback
-
4
freeze
-
end
-
-
1
def call attributes
-
5
attributes.fetch_value(key) { attributes.merge! key => fallback }
-
3
Success attributes
-
end
-
-
1
private
-
-
1
attr_reader :key, :fallback
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
-
1
module Etcher
-
1
module Transformers
-
# Formats given key using existing and/or placeholder attributes.
-
1
class Format
-
1
include Dry::Monads[:result]
-
-
1
def initialize key, *retainers, **mappings
-
10
@key = key
-
10
@retainers = retainers
-
10
@mappings = mappings
-
10
@pattern = /%<.+>s/o
-
10
freeze
-
end
-
-
1
def call attributes
-
9
value = attributes[key]
-
-
9
else: 5
then: 4
return Success attributes unless value && value.match?(pattern)
-
-
5
Success attributes.merge!(key => format(value, **attributes, **pass_throughs))
-
rescue KeyError => error
-
1
Failure step: :transform,
-
constant: self.class,
-
payload: "Unable to transform #{key.inspect}, missing specifier: " \
-
"\"#{error.message[/<.+>/]}\"."
-
end
-
-
1
private
-
-
1
attr_reader :key, :retainers, :mappings, :pattern
-
-
1
def pass_throughs
-
5
retainers.each
-
2
.with_object({}) { |key, expansions| expansions[key] = "%<#{key}>s" }
-
.merge! mappings
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
1
require "pathname"
-
1
require "refinements/hash"
-
-
1
module Etcher
-
1
module Transformers
-
# Conditionally updates value based on path.
-
1
class Root
-
1
include Dry::Monads[:result]
-
-
1
using Refinements::Hash
-
-
1
def initialize key, fallback: Pathname.pwd
-
5
@key = key
-
5
@fallback = fallback
-
5
freeze
-
end
-
-
1
def call attributes
-
7
value = attributes.fetch_value(key) { fallback }
-
4
Success attributes.merge!(key => Pathname(value).expand_path)
-
end
-
-
1
private
-
-
1
attr_reader :key, :fallback
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/monads"
-
-
1
module Etcher
-
1
module Transformers
-
# Conditionally transforms current time for key.
-
1
class Time
-
1
include Dry::Monads[:result]
-
-
1
def initialize key, fallback: ::Time.now.utc
-
8
@key = key
-
8
@fallback = fallback
-
8
freeze
-
end
-
-
1
def call attributes
-
7
attributes.fetch(key) { attributes.merge! key => fallback }
-
4
Success attributes
-
end
-
-
1
private
-
-
1
attr_reader :key, :fallback
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require "dry/types"
-
1
require "pathname"
-
1
require "versionaire"
-
-
1
module Etcher
-
# Defines custom types.
-
1
module Types
-
1
include Dry.Types(default: :strict)
-
-
1
Pathname = Constructor ::Pathname
-
1
Version = Constructor Versionaire::Version, Versionaire.method(:Version)
-
end
-
end