loading
Generated 2025-11-07T20:24:33+00:00

All Files ( 100.0% covered at 31.14 hits/line )

25 files in total.
538 relevant lines, 538 lines covered and 0 lines missed. ( 100.0% )
95 total branches, 95 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/sod.rb 100.00 % 19 10 10 0 1.30 100.00 % 0 0 0
lib/sod/action.rb 100.00 % 96 46 46 0 101.85 100.00 % 12 12 0
lib/sod/command.rb 100.00 % 85 39 39 0 22.41 100.00 % 8 8 0
lib/sod/container.rb 100.00 % 19 12 12 0 1.25 100.00 % 0 0 0
lib/sod/context.rb 100.00 % 32 15 15 0 9.27 100.00 % 2 2 0
lib/sod/dependencies.rb 100.00 % 7 3 3 0 1.00 100.00 % 0 0 0
lib/sod/error.rb 100.00 % 7 2 2 0 1.00 100.00 % 0 0 0
lib/sod/graph/loader.rb 100.00 % 40 23 23 0 41.78 100.00 % 0 0 0
lib/sod/graph/node.rb 100.00 % 110 60 60 0 119.03 100.00 % 22 22 0
lib/sod/graph/runner.rb 100.00 % 82 39 39 0 12.56 100.00 % 8 8 0
lib/sod/models/action.rb 100.00 % 38 11 11 0 44.82 100.00 % 0 0 0
lib/sod/models/command.rb 100.00 % 12 5 5 0 13.20 100.00 % 0 0 0
lib/sod/prefabs/actions/config/create.rb 100.00 % 73 39 39 0 3.95 100.00 % 7 7 0
lib/sod/prefabs/actions/config/delete.rb 100.00 % 59 30 30 0 2.07 100.00 % 4 4 0
lib/sod/prefabs/actions/config/edit.rb 100.00 % 46 23 23 0 3.17 100.00 % 4 4 0
lib/sod/prefabs/actions/config/view.rb 100.00 % 46 23 23 0 2.22 100.00 % 4 4 0
lib/sod/prefabs/actions/dry_run.rb 100.00 % 25 12 12 0 1.00 100.00 % 0 0 0
lib/sod/prefabs/actions/help.rb 100.00 % 34 17 17 0 7.29 100.00 % 2 2 0
lib/sod/prefabs/actions/version.rb 100.00 % 27 13 13 0 9.46 100.00 % 0 0 0
lib/sod/prefabs/commands/config.rb 100.00 % 21 11 11 0 1.00 100.00 % 0 0 0
lib/sod/presenters/action.rb 100.00 % 54 27 27 0 11.19 100.00 % 6 6 0
lib/sod/presenters/node.rb 100.00 % 97 49 49 0 13.00 100.00 % 12 12 0
lib/sod/refines/option_parser.rb 100.00 % 23 11 11 0 25.18 100.00 % 2 2 0
lib/sod/shell.rb 100.00 % 33 15 15 0 2.80 100.00 % 2 2 0
lib/sod/types/pathname.rb 100.00 % 6 3 3 0 1.33 100.00 % 0 0 0

lib/sod.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "zeitwerk"
  3. 1 Zeitwerk::Loader.new.then do |loader|
  4. 1 loader.ignore "#{__dir__}/sod/types"
  5. 1 loader.tag = File.basename __FILE__, ".rb"
  6. 1 loader.push_dir __dir__
  7. 1 loader.setup
  8. end
  9. # Main namespace.
  10. 1 module Sod
  11. 1 def self.loader registry = Zeitwerk::Registry
  12. 4 @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") }
  13. end
  14. 1 def self.new(...) = Shell.new(...)
  15. end

lib/sod/action.rb

100.0% lines covered

100.0% branches covered

46 relevant lines. 46 lines covered and 0 lines missed.
12 total branches, 12 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module Sod
  4. # A generic action (and DSL) from which to inherit and build custom actions from.
  5. # :reek:TooManyInstanceVariables
  6. 1 class Action
  7. 1 extend Forwardable
  8. 1 def self.inherited descendant
  9. 82 super
  10. 164 descendant.class_eval { @attributes = {} }
  11. end
  12. 1 def self.description text
  13. 14 then: 1 else: 13 @description ? fail(Error, "Description can only be defined once.") : @description = text
  14. end
  15. 1 def self.ancillary(*lines)
  16. 10 then: 1 else: 9 @ancillary ? fail(Error, "Ancillary can only be defined once.") : @ancillary = lines
  17. end
  18. 1 def self.on(aliases, **keywords)
  19. 81 then: 1 else: 80 fail Error, "On can only be defined once." if @attributes.any?
  20. 80 @attributes.merge! keywords, aliases: Array(aliases)
  21. end
  22. 1 def self.default &block
  23. 28 then: 1 else: 27 @default ? fail(Error, "Default can only be defined once.") : @default = block
  24. end
  25. 1 delegate [*Models::Action.members, :handle, :to_a, :to_h] => :record
  26. 1 attr_reader :record
  27. 1 def initialize context: Context::EMPTY, model: Models::Action
  28. 261 klass = self.class
  29. 261 @context = context
  30. 261 @record = model[
  31. **klass.instance_variable_get(:@attributes),
  32. description: load(:description),
  33. ancillary: Array(load(:ancillary)).compact,
  34. default: load_default
  35. ]
  36. 261 verify_aliases
  37. 260 verify_argument
  38. end
  39. 1 def call(*)
  40. 2 fail NoMethodError,
  41. "`#{self.class}##{__method__} #{method(__method__).parameters}` must be implemented."
  42. end
  43. 1 def inspect
  44. 24 attributes = record.to_h.map { |key, value| "#{key}=#{value.inspect}" }
  45. 3 %(#<#{self.class}:#{object_id} @context=#{context.inspect} #{attributes.join ", "}>)
  46. end
  47. 1 def to_proc = method(:call).to_proc
  48. 1 protected
  49. 1 attr_reader :context
  50. 1 private
  51. 1 def verify_aliases
  52. 261 else: 260 then: 1 fail Error, "Aliases must be defined." unless aliases
  53. end
  54. 1 def verify_argument
  55. 260 else: 1 then: 259 return unless argument && !argument.start_with?("[") && default
  56. 1 fail Error, "Required argument can't be used with default."
  57. end
  58. 1 def load attribute
  59. 522 klass = self.class
  60. 522 fallback = klass.instance_variable_get(:@attributes)[attribute]
  61. 522 klass.instance_variable_get(:"@#{attribute}") || fallback
  62. end
  63. 1 def load_default
  64. 261 klass = self.class
  65. 261 fallback = klass.instance_variable_get(:@attributes)[:default].method :itself
  66. 261 (klass.instance_variable_get(:@default) || fallback).call
  67. end
  68. end
  69. end

lib/sod/command.rb

100.0% lines covered

100.0% branches covered

39 relevant lines. 39 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module Sod
  4. # A generic command (and DSL) from which to inherit and build custom commands from.
  5. # :reek:TooManyInstanceVariables
  6. 1 class Command
  7. 1 extend Forwardable
  8. 1 include Dependencies[:logger]
  9. 1 def self.inherited descendant
  10. 61 super
  11. 122 descendant.class_eval { @actions = Set.new }
  12. end
  13. 1 def self.handle text
  14. 59 then: 1 else: 58 @handle ? fail(Error, "Handle can only be defined once.") : @handle = text
  15. end
  16. 1 def self.description text
  17. 23 then: 1 else: 22 @description ? fail(Error, "Description can only be defined once.") : @description = text
  18. end
  19. 1 def self.ancillary(*lines)
  20. 14 then: 1 else: 13 @ancillary ? fail(Error, "Ancillary can only be defined once.") : @ancillary = lines
  21. end
  22. 1 def self.on(action, *positionals, **keywords) = @actions.add [action, positionals, keywords]
  23. 1 delegate Models::Command.members => :record
  24. 1 attr_reader :record
  25. 1 def initialize(context: Context::EMPTY, model: Models::Command, **)
  26. 62 super(**)
  27. 62 @context = context
  28. 62 @record = build_record model
  29. 61 verify_handle
  30. end
  31. 1 def call
  32. 2 logger.debug { "`#{self.class}##{__method__}}` called without implementation. Skipped." }
  33. end
  34. 1 def inspect
  35. 2 attributes = record.to_h
  36. 10 .map { |key, value| "#{key}=#{value.inspect}" }
  37. .join ", "
  38. 2 "#<#{self.class}:#{object_id} @logger=#{logger.inspect} @context=#{context.inspect} " \
  39. "#{attributes}>"
  40. end
  41. 1 protected
  42. 1 attr_reader :context
  43. 1 private
  44. 1 def build_record model
  45. 62 klass = self.class
  46. 62 model[
  47. handle: klass.instance_variable_get(:@handle),
  48. description: klass.instance_variable_get(:@description),
  49. ancillary: Array(klass.instance_variable_get(:@ancillary)).compact,
  50. actions: Set[*build_actions],
  51. operation: method(:call)
  52. ]
  53. end
  54. 1 def build_actions
  55. 62 self.class.instance_variable_get(:@actions).map do |action, positionals, keywords|
  56. 64 action.new(*positionals, **keywords.merge!(context:))
  57. end
  58. end
  59. 1 def verify_handle
  60. 61 else: 59 then: 2 fail Error, "Invalid handle: #{handle.inspect}. Must be a string." unless handle in String
  61. end
  62. end
  63. end

lib/sod/container.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 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 "containable"
  4. 1 require "optparse"
  5. 1 require "tone"
  6. 1 module Sod
  7. # The primary container.
  8. 1 module Container
  9. 1 extend Containable
  10. 2 register(:client) { OptionParser.new nil, 40, " " }
  11. 2 register(:color) { Tone.new }
  12. 2 register(:logger) { Cogger.new id: :sod }
  13. 1 register :kernel, Kernel
  14. 1 register :io, STDOUT
  15. end
  16. end

lib/sod/context.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. # Provides a sharable, read-only, context for commands and actions.
  4. 1 class Context
  5. 1 EMPTY = new.freeze
  6. 1 def self.[](...) = new(...)
  7. 1 def initialize **attributes
  8. 16 @attributes = attributes
  9. end
  10. 1 def [] override, fallback
  11. 80 override || public_send(fallback)
  12. rescue NoMethodError
  13. 4 raise Error, "Invalid context. Override or fallback (#{fallback.inspect}) values are missing."
  14. end
  15. 1 def to_h = attributes.dup
  16. 15 then: 6 else: 8 def method_missing(name, *) = respond_to_missing?(name) ? attributes[name] : super
  17. 1 private
  18. 1 attr_reader :attributes
  19. 1 def respond_to_missing? name, include_private = false
  20. 14 (attributes && attributes.key?(name)) || super
  21. end
  22. end
  23. end

lib/sod/dependencies.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "infusible"
  3. 1 module Sod
  4. 1 Dependencies = Infusible[Container]
  5. end

lib/sod/error.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. # The namespaced root of all errors for this gem.
  4. 1 class Error < StandardError
  5. end
  6. end

lib/sod/graph/loader.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. 1 module Graph
  4. # Loads and decorates option parsers within graph.
  5. 1 class Loader
  6. 1 include Dependencies[:client]
  7. 1 using Refines::OptionParser
  8. 1 def initialize(graph, **)
  9. 36 super(**)
  10. 36 @graph = graph
  11. 36 @registry = {}
  12. end
  13. 1 def call
  14. 36 registry.clear
  15. 36 load graph
  16. 84 graph.children.each { |child| visit child, child.handle }
  17. 36 registry
  18. end
  19. 1 private
  20. 1 attr_reader :graph, :registry
  21. 1 def visit command, key = ""
  22. 74 load command, key
  23. 100 command.children.each { |child| visit child, "#{key} #{child.handle}".strip }
  24. end
  25. 1 def load node, key = ""
  26. 110 parser = client.replicate
  27. 256 node.actions.each { |action| parser.on(*action.to_a, action.to_proc) }
  28. 110 registry[key] = [parser, node]
  29. end
  30. end
  31. end
  32. end

lib/sod/graph/node.rb

100.0% lines covered

100.0% branches covered

60 relevant lines. 60 lines covered and 0 lines missed.
22 total branches, 22 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/array"
  3. 1 module Sod
  4. # :reek:DataClump
  5. 1 module Graph
  6. # A generic graph node (and DSL) from which to build multiple lineages with.
  7. 1 Node = Struct.new :handle, :description, :ancillary, :actions, :operation, :children do
  8. 1 using Refinements::Array
  9. 1 def initialize(**)
  10. 220 super
  11. 220 self[:actions] = Set.new actions
  12. 220 self[:children] = Set.new children
  13. 220 self[:ancillary] = Array ancillary
  14. 220 @depth = 0
  15. 220 @lineage = []
  16. end
  17. 1 def get_action *lineage
  18. 15 handle = lineage.pop
  19. 39 get_actions(*lineage).find { |action| action.handle.include? handle }
  20. end
  21. # :reek:DataClump
  22. 1 def get_actions *lineage, node: self
  23. 22 then: 17 else: 5 lineage.empty? ? node.actions : get(lineage, node, __method__)
  24. end
  25. 417 then: 273 else: 143 def get_child(*lineage, node: self) = lineage.empty? ? node : get(lineage, node, __method__)
  26. 1 def on(object, *, **, &)
  27. 267 then: 171 else: 96 lineage.clear if depth.zero?
  28. 267 process(object, *, **)
  29. 264 visit(&)
  30. 264 self
  31. end
  32. 21 then: 2 else: 18 def call = (operation.call if operation)
  33. 1 private
  34. 1 attr_reader :lineage
  35. 1 attr_accessor :depth
  36. 1 def process(object, *, **)
  37. 267 then: 178 else: 89 ancestry = object.is_a?(Class) ? object.ancestors : []
  38. 267 then: 44 if ancestry.include? Command
  39. 44 else: 223 add_child(*lineage, self.class[**object.new(*, **).record.to_h])
  40. 223 then: 89 elsif object.is_a? String
  41. 89 else: 134 add_inline_command(object, *, **)
  42. 134 then: 133 elsif ancestry.include? Action
  43. 133 add_action(*lineage, object.new(*, **))
  44. else: 1 else
  45. 1 fail Error, "Invalid command or action. Unable to add: #{object.inspect}."
  46. end
  47. end
  48. 1 def add_inline_command handle, *positionals
  49. 89 description, *ancillary = positionals
  50. 89 else: 88 then: 1 fail Error, <<~CONTENT unless handle && description
  51. Unable to add command. Invalid handle or description (both are required):
  52. - Handle: #{handle.inspect}
  53. - Description: #{description.inspect}
  54. CONTENT
  55. 88 add_child(*lineage, self.class[handle:, description:, ancillary: ancillary.compact])
  56. end
  57. 1 def add_child *lineage
  58. 132 node = lineage.pop
  59. 132 handle = node.handle
  60. 132 tracked_lineage = self.lineage
  61. 132 add lineage[...depth], node, :children
  62. 132 tracked_lineage.replace_at depth, handle
  63. end
  64. 1 def add_action(*lineage) = add lineage, lineage.pop, :actions
  65. 1 def add lineage, node, message
  66. 528 get_child(*lineage).then { |child| child.public_send(message).add node }
  67. end
  68. 1 def get lineage, node, message
  69. 148 handle = lineage.shift
  70. 382 node = node.children.find { |child| child.handle == handle }
  71. 148 else: 143 then: 5 fail Error, "Unable to find command or action: #{handle.inspect}." unless node
  72. 143 public_send(message, *lineage, node:)
  73. end
  74. 1 def visit &block
  75. 264 increment
  76. 264 then: 64 else: 200 instance_eval(&block) if block
  77. 264 decrement
  78. end
  79. 1 def increment = self.depth += 1
  80. 1 def decrement = self.depth -= 1
  81. end
  82. end
  83. end

lib/sod/graph/runner.rb

100.0% lines covered

100.0% branches covered

39 relevant lines. 39 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. 1 module Graph
  4. # Runs the appropriate parser for given command line arguments.
  5. 1 class Runner
  6. 1 include Dependencies[:client, :logger]
  7. 1 using Refines::OptionParser
  8. 1 HELP_PATTERN = /
  9. \A # Start of string.
  10. -h # Short alias.
  11. | # Or.
  12. --help # Long alias.
  13. \Z # End of string.
  14. /x
  15. # rubocop:todo Metrics/ParameterLists
  16. 1 def initialize(graph, help_pattern: HELP_PATTERN, loader: Loader, **)
  17. 28 super(**)
  18. 28 @graph = graph
  19. 28 @registry = loader.new(graph).call
  20. 28 @help_pattern = help_pattern
  21. 28 @lineage = +""
  22. end
  23. # rubocop:enable Metrics/ParameterLists
  24. # :reek:DuplicateMethodCall
  25. # :reek:TooManyStatements
  26. 1 def call arguments = ARGV
  27. 24 lineage.clear
  28. 24 visit arguments.dup
  29. rescue OptionParser::ParseError => error
  30. 3 log_error error.message
  31. rescue Sod::Error => error
  32. 2 log_error error.message
  33. 2 help
  34. end
  35. 1 private
  36. 1 attr_reader :graph, :registry, :help_pattern, :lineage
  37. # :reek:TooManyStatements
  38. 1 def visit arguments
  39. 78 then: 10 if arguments.empty? || arguments.any? { |argument| argument.match? help_pattern }
  40. 10 usage(*arguments)
  41. else: 24 else
  42. 24 parser, node = registry.fetch lineage, client
  43. 24 alter_callback_for parser
  44. 24 parser.order! arguments, command: node do |command|
  45. 10 lineage.concat(" ", command).tap(&:strip!)
  46. 10 visit arguments
  47. end
  48. end
  49. end
  50. # :reek:FeatureEnvy
  51. 1 def alter_callback_for parser
  52. 24 parser.define_singleton_method :callback! do |function, max_arity, value|
  53. 11 then: 5 else: 6 return function.call if function.arity == -1 && !value
  54. 6 super(function, max_arity, value)
  55. end
  56. end
  57. 1 def usage(*arguments)
  58. 10 commands = arguments.grep_v help_pattern
  59. 10 then: 8 else: 2 commands = lineage.split if commands.empty?
  60. 10 help(*commands)
  61. end
  62. 1 def help(*commands)
  63. 24 then: 11 else: 1 graph.get_action("help").then { |action| action.call(*commands) if action }
  64. end
  65. 6 def log_error(message) = logger.error { message.capitalize }
  66. end
  67. end
  68. end

lib/sod/models/action.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 "refinements/array"
  3. 1 module Sod
  4. 1 module Models
  5. # Defines all attributes of an action.
  6. 1 Action = Data.define(
  7. :aliases,
  8. :argument,
  9. :type,
  10. :allow,
  11. :default,
  12. :description,
  13. :ancillary
  14. ) do
  15. 1 using Refinements::Array
  16. 1 def initialize aliases: nil,
  17. argument: nil,
  18. type: nil,
  19. allow: nil,
  20. default: nil,
  21. description: nil,
  22. ancillary: nil
  23. 287 super
  24. end
  25. 1 def handle = [Array(aliases).join(", "), argument].tap(&:compact!).join " "
  26. 1 def to_a = [*handles, type, allow, description, *ancillary].tap(&:compress!)
  27. 1 private
  28. 197 def handles = Array(aliases).map { |item| [item, argument].tap(&:compact!).join " " }
  29. end
  30. end
  31. end

lib/sod/models/command.rb

100.0% lines covered

100.0% branches covered

5 relevant lines. 5 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. 1 module Models
  4. # Defines all attributes of a command.
  5. 1 Command = Data.define :handle, :description, :ancillary, :actions, :operation do
  6. 1 def initialize handle:, description:, actions:, operation:, ancillary: []
  7. 62 super
  8. end
  9. end
  10. end
  11. end

lib/sod/prefabs/actions/config/create.rb

100.0% lines covered

100.0% branches covered

39 relevant lines. 39 lines covered and 0 lines missed.
7 total branches, 7 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/pathname"
  3. 1 module Sod
  4. 1 module Prefabs
  5. 1 module Actions
  6. 1 module Config
  7. # Creates project configuration.
  8. 1 class Create < Action
  9. 1 include Dependencies[:kernel, :logger]
  10. 1 using Refinements::Pathname
  11. 1 description "Create default configuration."
  12. 1 ancillary "Prompts for local or global path."
  13. 1 on %w[-c --create]
  14. 1 def initialize(path = nil, xdg_config: nil, **)
  15. 11 super(**)
  16. 11 @xdg_config = context[xdg_config, :xdg_config]
  17. 10 @path = Pathname context[path, :defaults_path]
  18. end
  19. 1 def call(*)
  20. 10 ARGV.clear
  21. 10 valid_defaults? && choose
  22. end
  23. 1 private
  24. 1 attr_reader :path, :xdg_config
  25. 1 def valid_defaults?
  26. 10 then: 9 else: 1 return true if path.exist?
  27. 1 logger.abort "Default configuration doesn't exist: #{path.to_s.inspect}."
  28. 1 false
  29. end
  30. 1 def choose
  31. 9 kernel.print "Would you like to create (g)lobal, (l)ocal, or (n)o configuration? " \
  32. "(g/l/n)? "
  33. 9 response = kernel.gets.chomp
  34. 9 when: 3 case response
  35. 3 when: 3 when "g" then create xdg_config.global
  36. 3 else: 3 when "l" then create xdg_config.local
  37. 3 else quit
  38. end
  39. end
  40. # :reek:TooManyStatements
  41. 1 def create xdg_path
  42. 6 path_info = xdg_path.to_s.inspect
  43. 8 then: 2 else: 4 return logger.warn { "Skipped. Configuration exists: #{path_info}." } if xdg_path.exist?
  44. 4 path.copy xdg_path.make_ancestors
  45. 8 logger.info { "Created: #{path_info}." }
  46. end
  47. 1 def quit
  48. 6 logger.info { "Creation canceled." }
  49. 3 kernel.exit
  50. end
  51. end
  52. end
  53. end
  54. end
  55. end

lib/sod/prefabs/actions/config/delete.rb

100.0% lines covered

100.0% branches covered

30 relevant lines. 30 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/pathname"
  3. 1 require "refinements/string"
  4. 1 module Sod
  5. 1 module Prefabs
  6. 1 module Actions
  7. 1 module Config
  8. # Deletes project configuration.
  9. 1 class Delete < Action
  10. 1 include Dependencies[:kernel, :logger]
  11. 1 using Refinements::Pathname
  12. 1 using Refinements::String
  13. 1 description "Delete project configuration."
  14. 1 ancillary "Prompts for confirmation."
  15. 1 on %w[-d --delete]
  16. # :reek:ControlParameter
  17. 1 def initialize(path = nil, **)
  18. 6 super(**)
  19. 6 @path = Pathname(path || context.xdg_config.active)
  20. end
  21. 1 def call(*)
  22. 5 ARGV.clear
  23. 5 then: 4 else: 1 return confirm if path.exist?
  24. 2 logger.warn { "Skipped. Configuration doesn't exist: #{path_info}." }
  25. end
  26. 1 private
  27. 1 attr_reader :path
  28. 1 def confirm
  29. 4 kernel.print "Are you sure you want to delete #{path_info} (y/n)? "
  30. 4 then: 2 if kernel.gets.chomp.truthy?
  31. 2 path.delete
  32. 2 info "Deleted: #{path_info}."
  33. else: 2 else
  34. 2 info "Skipped: #{path_info}."
  35. end
  36. end
  37. 1 def path_info = path.to_s.inspect
  38. 5 def info(message) = logger.info { message }
  39. end
  40. end
  41. end
  42. end
  43. end

lib/sod/prefabs/actions/config/edit.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/pathname"
  3. 1 module Sod
  4. 1 module Prefabs
  5. 1 module Actions
  6. 1 module Config
  7. # Edits project configuration.
  8. 1 class Edit < Action
  9. 1 include Dependencies[:kernel, :logger]
  10. 1 using Refinements::Pathname
  11. 1 description "Edit project configuration."
  12. 1 on %w[-e --edit]
  13. # :reek:ControlParameter
  14. 1 def initialize(path = nil, **)
  15. 17 super(**)
  16. 17 @path = Pathname(path || context.xdg_config.active)
  17. end
  18. 1 def call(*)
  19. 5 else: 4 then: 1 return unless exist?
  20. 8 logger.info { "Editing: #{path.to_s.inspect}." }
  21. 4 kernel.system "$EDITOR #{path}"
  22. end
  23. 1 private
  24. 1 attr_reader :path
  25. 1 def exist?
  26. 5 then: 4 else: 1 return true if path.exist?
  27. 1 logger.abort "Configuration doesn't exist: #{path.to_s.inspect}."
  28. 1 false
  29. end
  30. end
  31. end
  32. end
  33. end
  34. end

lib/sod/prefabs/actions/config/view.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/pathname"
  3. 1 module Sod
  4. 1 module Prefabs
  5. 1 module Actions
  6. 1 module Config
  7. # Displays project configuration.
  8. 1 class View < Action
  9. 1 include Dependencies[:logger, :io]
  10. 1 using Refinements::Pathname
  11. 1 description "View project configuration."
  12. 1 on %w[-v --view]
  13. # :reek:ControlParameter
  14. 1 def initialize(path = nil, **)
  15. 6 super(**)
  16. 6 @path = Pathname(path || context.xdg_config.active)
  17. end
  18. 1 def call(*)
  19. 5 else: 4 then: 1 return unless exist?
  20. 8 logger.info { "Viewing (#{path.to_s.inspect}):" }
  21. 4 io.puts path.read
  22. end
  23. 1 private
  24. 1 attr_reader :path
  25. 1 def exist?
  26. 5 then: 4 else: 1 return true if path.exist?
  27. 1 logger.abort "Configuration doesn't exist: #{path.to_s.inspect}."
  28. 1 false
  29. end
  30. end
  31. end
  32. end
  33. end
  34. end

lib/sod/prefabs/actions/dry_run.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. 1 module Prefabs
  4. 1 module Actions
  5. # Displays help (usage) information.
  6. 1 class DryRun < Action
  7. 1 description "Simulate execution without making changes."
  8. 1 on %w[-n --dry_run]
  9. 1 def initialize(settings: Struct.new(:dry_run).new(dry_run: false), **)
  10. 1 super(**)
  11. 1 @settings = settings
  12. end
  13. 1 def call = settings.dry_run = true
  14. 1 private
  15. 1 attr_reader :settings
  16. end
  17. end
  18. end
  19. end

lib/sod/prefabs/actions/help.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sod
  3. 1 module Prefabs
  4. 1 module Actions
  5. # Displays help (usage) information.
  6. 1 class Help < Action
  7. 1 include Dependencies[:io]
  8. 1 description "Show this message."
  9. 1 on %w[-h --help], argument: "[COMMAND]"
  10. 1 def initialize(graph, presenter: Presenters::Node, **)
  11. 27 super(**)
  12. 27 @graph = graph
  13. 27 @presenter = presenter
  14. end
  15. 1 def call *lineage
  16. 16 then: 7 if lineage.empty?
  17. 7 io.puts presenter.new(graph).to_s
  18. else: 9 else
  19. 9 io.puts presenter.new(graph.get_child(*lineage)).to_s
  20. end
  21. end
  22. 1 private
  23. 1 attr_reader :graph, :presenter
  24. end
  25. end
  26. end
  27. end

lib/sod/prefabs/actions/version.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 module Sod
  3. 1 module Prefabs
  4. 1 module Actions
  5. # Provides a generic version action for use in upstream applications.
  6. 1 class Version < Action
  7. 1 include Dependencies[:io]
  8. 1 description "Show version."
  9. 1 on %w[-v --version]
  10. 1 def initialize(label = nil, **)
  11. 56 super(**)
  12. 56 @label = context[label, :version_label]
  13. end
  14. 1 def call(*) = io.puts label
  15. 1 private
  16. 1 attr_reader :label
  17. end
  18. end
  19. end
  20. end

lib/sod/prefabs/commands/config.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 module Sod
  3. 1 module Prefabs
  4. 1 module Commands
  5. # Provides a generic configuration command for use in upstream applications.
  6. 1 class Config < Sod::Command
  7. 1 handle "config"
  8. 1 description "Manage configuration."
  9. 1 ancillary "Path is dynamic per current directory."
  10. 1 on Prefabs::Actions::Config::Create
  11. 1 on Prefabs::Actions::Config::Edit
  12. 1 on Prefabs::Actions::Config::View
  13. 1 on Prefabs::Actions::Config::Delete
  14. end
  15. end
  16. end
  17. end

lib/sod/presenters/action.rb

100.0% lines covered

100.0% branches covered

27 relevant lines. 27 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 require "refinements/array"
  4. 1 module Sod
  5. 1 module Presenters
  6. # Aids in rendering an action for display.
  7. 1 class Action
  8. 1 include Dependencies[:color]
  9. 1 extend Forwardable
  10. 1 using Refinements::Array
  11. 1 delegate [*Models::Action.members, :handle] => :record
  12. 1 def initialize(record, **)
  13. 42 super(**)
  14. 42 @record = record
  15. end
  16. 1 def colored_handle = [color_aliases, argument].tap(&:compact!).join(" ")
  17. 1 def colored_documentation = [*ancillary, color_allows, color_default].tap(&:compact!)
  18. 1 private
  19. 1 attr_reader :record
  20. 1 def color_aliases
  21. 61 Array(record.aliases).map { |value| color[value, :cyan] }
  22. .join ", "
  23. end
  24. 1 def color_allows
  25. 26 else: 10 then: 16 return unless allow
  26. 30 values = Array(allow).map { |value| color[value, :green] }
  27. .to_sentence "or"
  28. 10 "Use: #{values}."
  29. end
  30. 1 def color_default
  31. 26 cast = default.to_s
  32. 26 then: 15 else: 11 return if cast.empty?
  33. 11 then: 1 else: 10 value = cast == "false" ? color[default, :red] : color[default, :green]
  34. 11 "Default: #{value}."
  35. end
  36. end
  37. end
  38. end

lib/sod/presenters/node.rb

100.0% lines covered

100.0% branches covered

49 relevant lines. 49 lines covered and 0 lines missed.
12 total branches, 12 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 require "refinements/array"
  4. 1 require "refinements/string"
  5. 1 module Sod
  6. 1 module Presenters
  7. # Aids in rendering a node for display.
  8. # :reek:TooManyInstanceVariables
  9. 1 class Node
  10. 1 include Dependencies[:color]
  11. 1 extend Forwardable
  12. 1 using Refinements::Array
  13. 1 using Refinements::String
  14. 1 delegate %i[handle description ancillary operation children] => :node
  15. 1 attr_reader :actions
  16. # rubocop:todo Metrics/ParameterLists
  17. 1 def initialize(node, indent: 2, gap: 5, action_presenter: Presenters::Action, **)
  18. 24 super(**)
  19. 24 @node = node
  20. 24 @indent = indent
  21. 24 @gap = gap
  22. 55 @actions = node.actions.map { |action| action_presenter.new action.record }
  23. 24 @all = actions + children.to_a
  24. end
  25. # rubocop:enable Metrics/ParameterLists
  26. 1 def to_s
  27. 20 [banner, body, "", *usage, "", *colored_actions, "", *colored_commands].tap(&:compact!)
  28. .join("\n")
  29. .strip
  30. end
  31. 1 private
  32. 1 attr_reader :node, :indent, :gap, :all
  33. 1 def banner = color[description, :bold]
  34. 21 then: 19 else: 1 def body = ancillary.empty? ? nil : ancillary.join("\n").prepend("\n")
  35. 1 def usage
  36. 20 else: 4 then: 16 actions = " #{colored_handle} [OPTIONS]" unless all.empty?
  37. 20 else: 8 then: 12 commands = " #{colored_handle} COMMAND [OPTIONS]" unless children.empty?
  38. 20 add_section "USAGE", [actions, commands].tap(&:compact!)
  39. end
  40. 1 def colored_handle = color[handle, :cyan]
  41. 1 def colored_actions
  42. 20 then: 5 else: 15 return if actions.empty?
  43. 15 collection = actions.each_with_object [] do |action, content|
  44. 23 content.append " #{action.colored_handle}#{description_padding action}" \
  45. "#{action.description}"
  46. 23 add_ancillary action, :colored_documentation, content
  47. end
  48. 15 add_section "OPTIONS", collection
  49. end
  50. 1 def colored_commands
  51. 20 then: 8 else: 12 return if children.empty?
  52. 12 collection = children.each_with_object [] do |command, content|
  53. 21 content.append " #{color[command.handle, :cyan]}#{description_padding command}" \
  54. "#{command.description}"
  55. 21 add_ancillary command, :ancillary, content
  56. end
  57. 12 add_section "COMMANDS", collection
  58. end
  59. 45 def description_padding(item) = " " * ((max_handle_size - item.handle.size) + gap)
  60. 1 def max_handle_size = all.map(&:handle).maximum :size
  61. 1 def add_ancillary target, message, content
  62. 44 target.public_send(message).each do |line|
  63. 19 content.append line.indent (max_handle_size + gap + indent), pad: " "
  64. end
  65. end
  66. # :reek:FeatureEnvy
  67. 1 def add_section text, collection
  68. 47 then: 4 else: 43 collection.empty? ? collection : collection.prepend(color[text, :bold, :underline])
  69. end
  70. end
  71. end
  72. end

lib/sod/refines/option_parser.rb

100.0% lines covered

100.0% branches covered

11 relevant lines. 11 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "optparse"
  3. 1 module Sod
  4. 1 module Refines
  5. # Provides additional enhancements to the option parser primitive.
  6. 1 module OptionParser
  7. 1 refine ::OptionParser do
  8. 1 def order!(argument = default_argv, into: nil, command: nil, **, &)
  9. 26 super(argument, into:, **, &)
  10. 20 then: 19 else: 1 command.call if command
  11. end
  12. 1 def replicate
  13. 112 self.class.new banner, summary_width, summary_indent do |instance|
  14. 112 instance.set_program_name program_name
  15. end
  16. end
  17. end
  18. end
  19. end
  20. end

lib/sod/shell.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "cogger"
  3. 1 module Sod
  4. # The Command Line Interface (CLI).
  5. 1 class Shell
  6. 1 attr_reader :name, :banner
  7. # rubocop:todo Metrics/ParameterLists
  8. 1 def initialize name = Cogger::Program.call,
  9. banner: nil,
  10. node: Graph::Node,
  11. runner: Graph::Runner,
  12. &block
  13. 6 @name = name.to_s
  14. 6 @banner = banner
  15. 6 graph = node[handle: name, description: banner]
  16. 6 then: 2 else: 4 graph.instance_eval(&block) if block
  17. 6 @runner = runner.new graph
  18. end
  19. # rubocop:enable Metrics/ParameterLists
  20. 1 def call arguments = ARGV, process: Process
  21. 2 process.setproctitle name
  22. 2 runner.call arguments
  23. end
  24. 1 private
  25. 1 attr_reader :runner
  26. end
  27. end

lib/sod/types/pathname.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "optparse"
  3. 1 require "pathname"
  4. 2 OptionParser.accept(Pathname) { |value| Pathname value }