lib/chef-cli/commands_map.rb



#
# Copyright:: Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module ChefCLI

  # CommandsMap maintains a mapping of subcommand names to the files where
  # those commands are defined and the classes that implement the commands.
  #
  # In ruby it's more typical to handle this sort of thing using conventions
  # and metaprogramming. We've implemented this approach in the past and
  # decided against it here:
  # 1. Performance. As the CLI suite grows, you have to load more and more
  # code, including dependencies that are installed by rubygems, etc. This gets
  # slow, and CLI apps need to be fast.
  # 2. You can workaround the above by having a convention mapping filename to
  # command name, but then you have to do a lot of work to list all of the
  # commands, which is actually a common thing to do.
  # 3. Other ways to mitigate the performance issue (loading deps lazily) have
  # their own complications and tradeoffs and don't fully solve the problem.
  # 4. It's not actually that much work to maintain the mapping.
  #
  # ## Adding new commands globally:
  #
  # A "singleton-ish" instance of this class is stored as ChefCLI.commands_map.
  # You can configure a multiple commands at once in a block using
  # ChefCLI.commands, like so:
  #
  #   ChefCLI.commands do |c|
  #     # assigns `chef my-command` to the class ChefCLI::Command::MyCommand.
  #     # The "require path" is inferred to be "chef-cli/command/my_command"
  #     c.builtin("my-command", :MyCommand)
  #
  #     # Set the require path explicitly:
  #     c.builtin("weird-command", :WeirdoClass, require_path: "chef-cli/command/this_is_cray")
  #
  #     # You can add a description that will show up in `chef -h` output (recommended):
  #     c.builtin("documented-cmd", :DocumentedCmd, desc: "A short description")
  #   end
  #
  class CommandsMap
    NULL_ARG = Object.new

    CommandSpec = Struct.new(:name, :constant_name, :require_path, :description, :hidden)

    class CommandSpec

      def instantiate
        require require_path
        command_class = ChefCLI::Command.const_get(constant_name)
        command_class.new
      end

    end

    attr_reader :command_specs

    def initialize
      @command_specs = {}
    end

    def builtin(name, constant_name, require_path: NULL_ARG, desc: "", hidden: false)
      if null?(require_path)
        snake_case_path = name.tr("-", "_")
        require_path = "chef-cli/command/#{snake_case_path}"
      end
      command_specs[name] = CommandSpec.new(name, constant_name, require_path, desc, hidden)
    end

    def instantiate(name)
      spec_for(name).instantiate
    end

    def have_command?(name)
      command_specs.key?(name)
    end

    def command_names
      command_specs.keys
    end

    def spec_for(name)
      command_specs[name]
    end

    private

    def null?(argument)
      argument.equal?(NULL_ARG)
    end
  end

  def self.commands_map
    @commands_map ||= CommandsMap.new
  end

  def self.commands
    yield commands_map
  end
end