loading
Generated 2025-11-07T20:29:25+00:00

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% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/etcher.rb 100.00 % 24 13 13 0 1.85 100.00 % 0 0 0
lib/etcher/builder.rb 100.00 % 64 34 34 0 10.50 100.00 % 2 2 0
lib/etcher/contract.rb 100.00 % 11 5 5 0 8.20 100.00 % 2 2 0
lib/etcher/finder.rb 100.00 % 21 9 9 0 8.67 100.00 % 2 2 0
lib/etcher/loaders/environment.rb 100.00 % 25 13 13 0 1.92 100.00 % 0 0 0
lib/etcher/loaders/hash.rb 100.00 % 23 11 11 0 1.36 100.00 % 0 0 0
lib/etcher/loaders/json.rb 100.00 % 51 28 28 0 4.18 100.00 % 2 2 0
lib/etcher/loaders/yaml.rb 100.00 % 82 42 42 0 3.24 100.00 % 3 3 0
lib/etcher/registry.rb 100.00 % 45 23 23 0 9.52 100.00 % 5 5 0
lib/etcher/resolver.rb 100.00 % 43 22 22 0 2.86 100.00 % 5 5 0
lib/etcher/transformers/basename.rb 100.00 % 31 17 17 0 1.88 100.00 % 0 0 0
lib/etcher/transformers/format.rb 100.00 % 43 21 21 0 4.33 100.00 % 2 2 0
lib/etcher/transformers/root.rb 100.00 % 31 17 17 0 2.24 100.00 % 0 0 0
lib/etcher/transformers/time.rb 100.00 % 27 14 14 0 3.14 100.00 % 0 0 0
lib/etcher/types.rb 100.00 % 15 8 8 0 1.00 100.00 % 0 0 0

lib/etcher.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "cogger"
  3. 1 require "zeitwerk"
  4. 1 Zeitwerk::Loader.new.then do |loader|
  5. 1 loader.push_dir __dir__
  6. 1 loader.inflector.inflect "json" => "JSON", "yaml" => "YAML"
  7. 1 loader.tag = File.basename __FILE__, ".rb"
  8. 1 loader.setup
  9. end
  10. # Main namespace.
  11. 1 module Etcher
  12. 1 LOGGER = Cogger.new id: :etcher
  13. 1 def self.loader registry = Zeitwerk::Registry
  14. 12 @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") }
  15. end
  16. 1 def self.new(...) = Builder.new(...)
  17. 1 def self.call(registry = Registry.new, **) = Resolver.new(registry).call(**)
  18. end

lib/etcher/builder.rb

100.0% lines covered

100.0% branches covered

34 relevant lines. 34 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 require "refinements/hash"
  5. 1 module Etcher
  6. # Builds a configuration.
  7. 1 class Builder
  8. 1 include Dry::Monads[:result]
  9. 1 using Refinements::Hash
  10. 1 def initialize registry = Registry.new
  11. 29 @registry = registry
  12. 29 freeze
  13. end
  14. 1 def call(**overrides)
  15. 50 load.bind { |attributes| transform attributes }
  16. 20 .fmap { |attributes| attributes.merge! overrides.symbolize_keys! }
  17. 20 .bind { |attributes| validate attributes }
  18. 18 .bind { |attributes| model attributes }
  19. end
  20. 1 private
  21. 1 attr_reader :registry
  22. 1 def load
  23. 26 registry.loaders
  24. 22 .map { |loader| loader.call.fmap { |pairs| pairs.flatten_keys.symbolize_keys! } }
  25. 12 .reduce(Success({})) { |all, result| merge all, result }
  26. end
  27. 1 def transform attributes
  28. 24 registry.transformers.reduce Success(attributes) do |all, transformer|
  29. 8 merge all, transformer.call(attributes)
  30. end
  31. end
  32. 1 def validate attributes
  33. 20 registry.contract
  34. .call(attributes)
  35. .to_monad
  36. .or do |result|
  37. 2 Failure step: __method__, constant: self.class, payload: result.errors.to_h
  38. end
  39. end
  40. 1 def model attributes
  41. 18 Success registry.model[**attributes.to_h].freeze
  42. rescue ArgumentError => error
  43. 3 Failure step: __method__, constant: self.class, payload: "#{error.message.capitalize}."
  44. end
  45. 1 def merge(*items)
  46. 20 in: 14 case items
  47. 14 else: 6 in Success(all), Success(subset) then Success(all.merge!(subset))
  48. 6 else items.last
  49. end
  50. end
  51. end
  52. end

lib/etcher/contract.rb

100.0% lines covered

100.0% branches covered

5 relevant lines. 5 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Etcher
  4. # A simple passthrough contract.
  5. 1 Contract = lambda do |result|
  6. 19 else: 1 then: 18 def result.to_monad = Dry::Monads::Success self unless result.respond_to? :to_monad
  7. 19 result
  8. end
  9. end

lib/etcher/finder.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. # Finds internal constant if moniker matches, otherwise answers a failure.
  4. 1 module Etcher
  5. 1 include Dry::Monads[:result]
  6. 1 Finder = lambda do |namespace, moniker|
  7. 21 Etcher.const_get(namespace)
  8. .constants
  9. 30 .find { |constant| constant.downcase == moniker }
  10. .then do |constant|
  11. 20 then: 18 else: 2 return Dry::Monads::Success Etcher.const_get("#{namespace}::#{constant}") if constant
  12. 2 Dry::Monads::Failure "Unable to select #{moniker.inspect} within #{namespace.downcase}."
  13. end
  14. rescue NameError
  15. 1 Dry::Monads::Failure "Invalid namespace: #{namespace.inspect}."
  16. end
  17. end

lib/etcher/loaders/environment.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 module Etcher
  5. 1 module Loaders
  6. # Loads environment configuration with optional includes.
  7. 1 class Environment
  8. 1 include Dry::Monads[:result]
  9. 1 def initialize attributes = ENV, only: Core::EMPTY_ARRAY
  10. 5 @attributes = attributes
  11. 5 @only = Array only
  12. 5 freeze
  13. end
  14. 1 def call = Success attributes.slice(*only).transform_keys(&:downcase)
  15. 1 private
  16. 1 attr_reader :attributes, :only
  17. end
  18. end
  19. end

lib/etcher/loaders/hash.rb

100.0% lines covered

100.0% branches covered

11 relevant lines. 11 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Etcher
  4. 1 module Loaders
  5. # Loads (wraps) raw attributes.
  6. 1 class Hash
  7. 1 include Dry::Monads[:result]
  8. 1 def initialize(**attributes)
  9. 3 @attributes = attributes
  10. 3 freeze
  11. end
  12. 1 def call = Success attributes
  13. 1 private
  14. 1 attr_reader :attributes
  15. end
  16. end
  17. end

lib/etcher/loaders/json.rb

100.0% lines covered

100.0% branches covered

28 relevant lines. 28 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 require "json"
  5. 1 module Etcher
  6. 1 module Loaders
  7. # Loads a JSON configuration.
  8. 1 class JSON
  9. 1 include Dry::Monads[:result]
  10. 1 def initialize path, fallback: Core::EMPTY_HASH, logger: LOGGER
  11. 17 @path = path
  12. 17 @fallback = fallback
  13. 17 @logger = logger
  14. 17 freeze
  15. end
  16. 1 def call
  17. 12 Success ::JSON.load_file(path)
  18. 2 rescue Errno::ENOENT, TypeError then debug_invalid_path
  19. 3 rescue ::JSON::ParserError => error then content_failure error
  20. end
  21. 1 private
  22. 1 attr_reader :path, :fallback, :logger
  23. 1 def debug_invalid_path
  24. 4 logger.debug { "Invalid path: #{path_info}. Using fallback." }
  25. 2 Success fallback
  26. end
  27. 1 def content_failure error
  28. 3 constant = self.class
  29. 3 token = error.message[/(?<token>'.+?')/, :token].to_s.tr "'", ""
  30. 3 then: 2 if token.empty?
  31. 2 Failure step: :load, constant:, payload: "File is empty: #{path_info}."
  32. else: 1 else
  33. 1 Failure step: :load,
  34. constant:,
  35. payload: "Invalid content: #{token.inspect}. Path: #{path_info}."
  36. end
  37. end
  38. 1 def path_info = path.to_s.inspect
  39. end
  40. end
  41. end

lib/etcher/loaders/yaml.rb

100.0% lines covered

100.0% branches covered

42 relevant lines. 42 lines covered and 0 lines missed.
3 total branches, 3 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 require "refinements/string"
  5. 1 require "yaml"
  6. 1 module Etcher
  7. 1 module Loaders
  8. # Loads a YAML configuration.
  9. 1 class YAML
  10. 1 include Dry::Monads[:result]
  11. 1 using Refinements::String
  12. 1 def initialize path, fallback: Core::EMPTY_HASH, logger: LOGGER
  13. 14 @path = path
  14. 14 @fallback = fallback
  15. 14 @logger = logger
  16. 14 freeze
  17. end
  18. 1 def call
  19. 13 load
  20. 2 rescue Errno::ENOENT, TypeError then debug_invalid_path
  21. 1 rescue Psych::AliasesNotEnabled then alias_failure
  22. 1 rescue Psych::DisallowedClass => error then disallowed_failure error
  23. 1 rescue Psych::SyntaxError => error then syntax_failure error
  24. end
  25. 1 private
  26. 1 attr_reader :path, :fallback, :logger
  27. 1 def load
  28. 13 content = ::YAML.safe_load_file path
  29. 8 in: 5 case content
  30. 5 in: 2 in ::Hash then Success content
  31. 2 else: 1 in nil then empty_failure
  32. 1 else invalid_failure content
  33. end
  34. end
  35. 1 def debug_invalid_path
  36. 4 logger.debug { "Invalid path: #{path_info}. Using fallback." }
  37. 2 Success fallback
  38. end
  39. 1 def empty_failure
  40. 2 Failure step: :load, constant: self.class, payload: "File is empty: #{path_info}."
  41. end
  42. 1 def invalid_failure content
  43. 1 Failure step: :load,
  44. constant: self.class,
  45. payload: "Invalid content: #{content.inspect}. Path: #{path_info}."
  46. end
  47. 1 def alias_failure
  48. 1 Failure step: :load,
  49. constant: self.class,
  50. payload: "Aliases are disabled, please remove. Path: #{path_info}."
  51. end
  52. 1 def disallowed_failure error
  53. 1 Failure step: :load,
  54. constant: self.class,
  55. payload: "Invalid type, #{error.message.down}. Path: #{path_info}."
  56. end
  57. 1 def syntax_failure error
  58. 1 Failure step: :load,
  59. constant: self.class,
  60. payload: "Invalid syntax, #{error.message[/found.+/]}. " \
  61. "Path: #{path_info}."
  62. end
  63. 1 def path_info = path.to_s.inspect
  64. end
  65. end
  66. end

lib/etcher/registry.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
5 total branches, 5 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Etcher
  3. # Provides a registry of customization for loading and resolving a configuration.
  4. 1 Registry = Data.define :contract, :model, :loaders, :transformers do
  5. 1 def self.find namespace, moniker, logger: LOGGER
  6. 19 in: 17 case Finder.call namespace, moniker
  7. 17 in: 1 in Success(constant) then constant
  8. 1 else: 1 in Failure(message) then logger.abort message
  9. 1 else logger.abort "Unable to find constant in registry."
  10. end
  11. end
  12. 1 def initialize contract: Contract, model: Hash, loaders: [], transformers: []
  13. 42 super
  14. end
  15. 1 def add_loader(loader, ...) = add(loader, :Loaders, ...)
  16. 1 def remove_loader(index) = remove index, loaders
  17. 1 def add_transformer(transformer, ...) = add(transformer, :Transformers, ...)
  18. 1 def remove_transformer(index) = remove index, transformers
  19. 1 private
  20. 1 def add(item, namespace, ...)
  21. 26 collection = __send__ namespace.downcase
  22. 26 then: 16 if item.is_a? Symbol
  23. 32 self.class.find(namespace, item).then { |kind| collection.append kind.new(...) }
  24. else: 10 else
  25. 10 collection.append item
  26. end
  27. 26 self
  28. end
  29. 1 def remove index, collection
  30. 4 collection.delete_at index
  31. 4 self
  32. end
  33. end
  34. end

lib/etcher/resolver.rb

100.0% lines covered

100.0% branches covered

22 relevant lines. 22 lines covered and 0 lines missed.
5 total branches, 5 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 require "refinements/array"
  4. 1 module Etcher
  5. # Builds and fully resolves a configuration.
  6. 1 class Resolver
  7. 1 include Dry::Monads[:result]
  8. 1 using Refinements::Array
  9. 1 def initialize registry = Registry.new, logger: LOGGER
  10. 10 @builder = Builder.new registry
  11. 10 @logger = logger
  12. 10 freeze
  13. end
  14. 1 def call(**overrides)
  15. 9 in: 3 case builder.call(**overrides)
  16. 3 in Success(attributes) then attributes
  17. in: 3 in Failure(step:, constant:, payload: String => payload)
  18. 3 logger.abort "#{step.capitalize} failure (#{constant}). #{payload}"
  19. in: 1 in Failure(step:, constant:, payload: Hash => payload)
  20. 1 in: 1 log_and_abort step, constant, payload
  21. 1 else: 1 in Failure(String => message) then logger.abort message
  22. 1 else logger.abort "Unable to parse failure."
  23. end
  24. end
  25. 1 private
  26. 1 attr_reader :builder, :logger
  27. 1 def log_and_abort step, constant, errors
  28. 3 details = errors.map { |key, message| " - #{key} #{message.to_sentence}\n" }
  29. .join
  30. 1 logger.abort "#{step.capitalize} failure (#{constant}). " \
  31. "Unable to load configuration:\n#{details}"
  32. end
  33. end
  34. end

lib/etcher/transformers/basename.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 require "pathname"
  4. 1 require "refinements/hash"
  5. 1 module Etcher
  6. 1 module Transformers
  7. # Conditionally updates value based on path.
  8. 1 class Basename
  9. 1 include Dry::Monads[:result]
  10. 1 using Refinements::Hash
  11. 1 def initialize key, fallback: Pathname.pwd.basename.to_s
  12. 4 @key = key
  13. 4 @fallback = fallback
  14. 4 freeze
  15. end
  16. 1 def call attributes
  17. 5 attributes.fetch_value(key) { attributes.merge! key => fallback }
  18. 3 Success attributes
  19. end
  20. 1 private
  21. 1 attr_reader :key, :fallback
  22. end
  23. end
  24. end

lib/etcher/transformers/format.rb

100.0% lines covered

100.0% branches covered

21 relevant lines. 21 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Etcher
  4. 1 module Transformers
  5. # Formats given key using existing and/or placeholder attributes.
  6. 1 class Format
  7. 1 include Dry::Monads[:result]
  8. 1 def initialize key, *retainers, **mappings
  9. 10 @key = key
  10. 10 @retainers = retainers
  11. 10 @mappings = mappings
  12. 10 @pattern = /%<.+>s/o
  13. 10 freeze
  14. end
  15. 1 def call attributes
  16. 9 value = attributes[key]
  17. 9 else: 5 then: 4 return Success attributes unless value && value.match?(pattern)
  18. 5 Success attributes.merge!(key => format(value, **attributes, **pass_throughs))
  19. rescue KeyError => error
  20. 1 Failure step: :transform,
  21. constant: self.class,
  22. payload: "Unable to transform #{key.inspect}, missing specifier: " \
  23. "\"#{error.message[/<.+>/]}\"."
  24. end
  25. 1 private
  26. 1 attr_reader :key, :retainers, :mappings, :pattern
  27. 1 def pass_throughs
  28. 5 retainers.each
  29. 2 .with_object({}) { |key, expansions| expansions[key] = "%<#{key}>s" }
  30. .merge! mappings
  31. end
  32. end
  33. end
  34. end

lib/etcher/transformers/root.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 require "pathname"
  4. 1 require "refinements/hash"
  5. 1 module Etcher
  6. 1 module Transformers
  7. # Conditionally updates value based on path.
  8. 1 class Root
  9. 1 include Dry::Monads[:result]
  10. 1 using Refinements::Hash
  11. 1 def initialize key, fallback: Pathname.pwd
  12. 5 @key = key
  13. 5 @fallback = fallback
  14. 5 freeze
  15. end
  16. 1 def call attributes
  17. 7 value = attributes.fetch_value(key) { fallback }
  18. 4 Success attributes.merge!(key => Pathname(value).expand_path)
  19. end
  20. 1 private
  21. 1 attr_reader :key, :fallback
  22. end
  23. end
  24. end

lib/etcher/transformers/time.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Etcher
  4. 1 module Transformers
  5. # Conditionally transforms current time for key.
  6. 1 class Time
  7. 1 include Dry::Monads[:result]
  8. 1 def initialize key, fallback: ::Time.now.utc
  9. 8 @key = key
  10. 8 @fallback = fallback
  11. 8 freeze
  12. end
  13. 1 def call attributes
  14. 7 attributes.fetch(key) { attributes.merge! key => fallback }
  15. 4 Success attributes
  16. end
  17. 1 private
  18. 1 attr_reader :key, :fallback
  19. end
  20. end
  21. end

lib/etcher/types.rb

100.0% lines covered

100.0% branches covered

8 relevant lines. 8 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/types"
  3. 1 require "pathname"
  4. 1 require "versionaire"
  5. 1 module Etcher
  6. # Defines custom types.
  7. 1 module Types
  8. 1 include Dry.Types(default: :strict)
  9. 1 Pathname = Constructor ::Pathname
  10. 1 Version = Constructor Versionaire::Version, Versionaire.method(:Version)
  11. end
  12. end