lib/chefspec/coverage.rb



require_relative "coverage/filters"

module ChefSpec
  class Coverage
    EXIT_FAILURE = 1
    EXIT_SUCCESS = 0

    class << self
      def method_added(name)
        # Only delegate public methods
        if method_defined?(name)
          instance_eval <<-EOH, __FILE__, __LINE__ + 1
            def #{name}(*args, &block)
              instance.public_send(:#{name}, *args, &block)
            end
          EOH
        end
      end
    end

    include Singleton

    attr_reader :filters

    #
    # Create a new coverage object singleton.
    #
    def initialize
      @collection = {}
      @filters    = {}
      @outputs    = []
      add_output do |report|

        erb = Erubis::Eruby.new(File.read(@template))
        puts erb.evaluate(report)
      rescue NameError => e
        raise Error::ErbTemplateParseError.new(original_error: e.message)

      end
      @template = ChefSpec.root.join("templates", "coverage", "human.erb")
    end

    #
    # Start the coverage reporting analysis. This method also adds the the
    # +at_exit+ handler for printing the coverage report.
    #
    def start!(&block)
      warn("ChefSpec's coverage reporting is deprecated and will be removed in a future version")
      instance_eval(&block) if block
      at_exit { ChefSpec::Coverage.report! }
    end

    #
    # Add a filter to the coverage analysis.
    #
    # @param [Filter, String, Regexp] filter
    #   the filter to add
    # @param [Proc] block
    #   the block to use as a filter
    #
    # @return [true]
    #
    def add_filter(filter = nil, &block)
      id = "#{filter.inspect}/#{block.inspect}".hash

      @filters[id] = if filter.is_a?(Filter)
                       filter
                     elsif filter.is_a?(String)
                       StringFilter.new(filter)
                     elsif filter.is_a?(Regexp)
                       RegexpFilter.new(filter)
                     elsif block
                       BlockFilter.new(block)
                     else
                       raise ArgumentError, "Please specify either a string, " \
                         "filter, or block to filter source files with!"
                     end

      true
    end

    #
    # Add an output to send the coverage results to.
    # @param [Proc] block
    #   the block to use as the output
    #
    # @return [true]
    #
    def add_output(&block)
      @outputs << block
    end

    #
    # Change the template for reporting of converage analysis.
    #
    # @param [string] path
    #   The template file to use for the output of the report
    #
    # @return [true]
    #
    def set_template(file = "human.erb")
      @template = [
        ChefSpec.root.join("templates", "coverage", file),
        File.expand_path(file, Dir.pwd),
      ].find { |f| File.exist?(f) }
      raise Error::TemplateNotFound.new(path: file) unless @template
    end

    #
    # Add a resource to the resource collection. Only new resources are added
    # and only resources that match the given filter are covered (which is *
    # by default).
    #
    # @param [Chef::Resource] resource
    #
    def add(resource)
      if !exists?(resource) && !filtered?(resource)
        @collection[resource.to_s] = ResourceWrapper.new(resource)
      end
    end

    #
    # Called when a resource is matched to indicate it has been tested.
    #
    # @param [Chef::Resource] resource
    #
    def cover!(resource)
      wrapper = find(resource)
      wrapper.touch! if wrapper
    end

    #
    # Called to check if a resource belongs to a cookbook from the specified
    # directories.
    #
    # @param [Chef::Resource] resource
    #
    def filtered?(resource)
      filters.any? { |_, filter| filter.matches?(resource) }
    end

    #
    # Generate a coverage report. This report **must** be generated +at_exit+
    # or else the entire resource collection may not be complete!
    #
    # @example Generating a report
    #
    #   ChefSpec::Coverage.report!
    #
    def report!
      # Borrowed from simplecov#41
      #
      # If an exception is thrown that isn't a "SystemExit", we need to capture
      # that error and re-raise.
      if $!
        exit_status = $!.is_a?(SystemExit) ? $!.status : EXIT_FAILURE
      else
        exit_status = EXIT_SUCCESS
      end

      report = {}.tap do |h|
        h[:total]     = @collection.size
        h[:touched]   = @collection.count { |_, resource| resource.touched? }
        h[:coverage]  = ((h[:touched] / h[:total].to_f) * 100).round(2)
      end

      report[:untouched_resources] = @collection.collect do |_, resource|
        resource unless resource.touched?
      end.compact
      report[:all_resources] = @collection.values

      @outputs.each do |block|
        instance_exec(report, &block)
      end

      # Ensure we exit correctly (#351)
      Kernel.exit(exit_status) if exit_status && exit_status > 0
    end

    private

    def find(resource)
      @collection[resource.to_s]
    end

    def exists?(resource)
      !find(resource).nil?
    end

    class ResourceWrapper
      attr_reader :resource

      def initialize(resource = nil)
        @resource = resource
      end

      def to_s
        @resource.to_s
      end

      def to_json
        {
          "source_file" => source_file,
          "source_line" => source_line,
          "touched" => touched?,
          "resource" => to_s,
        }.to_json
      end

      def source_file
        @source_file ||= if @resource.source_line
                           shortname(@resource.source_line.split(":").first)
                         else
                           "Unknown"
                         end
      end

      def source_line
        @source_line ||= if @resource.source_line
                           @resource.source_line.split(":", 2).last.to_i
                         else
                           "Unknown"
                         end
      end

      def touch!
        @touched = true
      end

      def touched?
        !!@touched
      end

      private

      def shortname(file)
        if file.include?(Dir.pwd)
          file.split(Dir.pwd, 2).last
        elsif file.include?("cookbooks")
          file.split("cookbooks/", 2).last
        else
          file
        end
      end
    end
  end
end