lib/chef-cli/policyfile/differ.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.
#

require "diff/lcs"
require "diff/lcs/hunk"
require "pastel" unless defined?(Pastel)
autoload :FFI_Yajl, "ffi_yajl"

module ChefCLI
  module Policyfile
    class Differ

      POLICY_SECTIONS = %w{ revision_id run_list named_run_lists cookbook_locks default_attributes override_attributes }.freeze
      LINES_OF_CONTEXT = 3
      INITIAL_FILE_LENGTH_DIFFERENCE = 0
      FORMAT = :unified

      attr_reader :old_lock
      attr_reader :old_name
      attr_reader :new_lock
      attr_reader :new_name
      attr_reader :ui

      def initialize(old_name: nil, old_lock: nil, new_name: nil, new_lock: nil, ui: nil)
        @old_lock = old_lock
        @new_lock = new_lock
        @old_name = old_name
        @new_name = new_name
        @ui = ui

        @added_cookbooks    = nil
        @removed_cookbooks  = nil
        @modified_cookbooks = nil
      end

      def lock_name
        old_lock["name"]
      end

      def old_cookbook_locks
        old_lock["cookbook_locks"]
      end

      def new_cookbook_locks
        new_lock["cookbook_locks"]
      end

      def updated_sections
        @updated_sections ||= POLICY_SECTIONS.select do |key|
          old_lock[key] != new_lock[key]
        end
      end

      def different?
        !updated_sections.empty?
      end

      def run_report
        unless different?
          ui.err("No changes for policy lock '#{lock_name}' between '#{old_name}' and '#{new_name}'")
          return true
        end

        ui.print("Policy lock '#{lock_name}' differs between '#{old_name}' and '#{new_name}':\n\n")

        report_rev_id_changes
        report_run_list_changes
        report_added_cookbooks
        report_removed_cookbooks
        report_modified_cookbooks
        report_default_attribute_changes
        report_override_attribute_changes
      end

      def report_rev_id_changes
        h1("REVISION ID CHANGED")
        old_rev = old_lock["revision_id"]
        new_rev = new_lock["revision_id"]
        diff_lines([ old_rev ], [ new_rev ])
      end

      def report_run_list_changes
        return nil unless updated_sections.include?("run_list")

        h1("RUN LIST CHANGED")

        old_run_list = old_lock["run_list"]
        new_run_list = new_lock["run_list"]

        diff_lines(old_run_list, new_run_list)
      end

      def report_removed_cookbooks
        return nil if removed_cookbooks.empty?

        h1("REMOVED COOKBOOKS")
        removed_cookbooks.each do |cb_name|
          ui.print("\n")
          old_lock = pretty_json(old_cookbook_locks[cb_name])
          new_lock = []
          h2(cb_name)
          diff_lines(old_lock, new_lock)
        end
      end

      def report_added_cookbooks
        return nil if added_cookbooks.empty?

        h1("ADDED COOKBOOKS")
        added_cookbooks.each do |cb_name|
          ui.print("\n")
          old_lock = []
          new_lock = pretty_json(new_cookbook_locks[cb_name])
          h2(cb_name)
          diff_lines(old_lock, new_lock)
        end
      end

      def report_modified_cookbooks
        return nil if modified_cookbooks.empty?

        h1("MODIFIED COOKBOOKS")
        modified_cookbooks.each do |cb_name|
          ui.print("\n")
          old_lock = pretty_json(old_cookbook_locks[cb_name])
          new_lock = pretty_json(new_cookbook_locks[cb_name])
          h2(cb_name)
          diff_lines(old_lock, new_lock)
        end
      end

      def report_default_attribute_changes
        return nil unless updated_sections.include?("default_attributes")

        h1("DEFAULT ATTRIBUTES CHANGED")

        old_default = pretty_json(old_lock["default_attributes"])
        new_default = pretty_json(new_lock["default_attributes"])
        diff_lines(old_default, new_default)
      end

      def report_override_attribute_changes
        return nil unless updated_sections.include?("override_attributes")

        h1("OVERRIDE ATTRIBUTES CHANGED")

        old_override = pretty_json(old_lock["override_attributes"])
        new_override = pretty_json(new_lock["override_attributes"])
        diff_lines(old_override, new_override)
      end

      def added_cookbooks
        detect_cookbook_changes if @added_cookbooks.nil?
        @added_cookbooks
      end

      def removed_cookbooks
        detect_cookbook_changes if @removed_cookbooks.nil?
        @removed_cookbooks
      end

      def modified_cookbooks
        detect_cookbook_changes if @modified_cookbooks.nil?
        @modified_cookbooks
      end

      private

      def h1(str)
        ui.msg(str)
        ui.msg("=" * str.size)
      end

      def h2(str)
        ui.msg(str)
        ui.msg("-" * str.size)
      end

      def diff_lines(old_lines, new_lines)
        file_length_difference = INITIAL_FILE_LENGTH_DIFFERENCE

        previous_hunk = nil

        diffs = Diff::LCS.diff(old_lines, new_lines)

        ui.print("\n")

        diffs.each do |piece|
          hunk = Diff::LCS::Hunk.new(old_lines, new_lines, piece, LINES_OF_CONTEXT, file_length_difference)

          file_length_difference = hunk.file_length_difference

          maybe_contiguous_hunks = (previous_hunk.nil? || hunk.merge(previous_hunk))

          unless maybe_contiguous_hunks
            print_color_diff("#{previous_hunk.diff(FORMAT)}\n")
          end
          previous_hunk = hunk
        end
        print_color_diff("#{previous_hunk.diff(FORMAT)}\n") unless previous_hunk.nil?
        ui.print("\n")
      end

      def pastel
        @pastel ||= Pastel.new
      end

      def print_color_diff(hunk)
        hunk.to_s.each_line do |line|
          line_color = color_for_line(line)
          line = pastel.decorate(line, line_color) unless line_color.nil?
          ui.print(line)
        end
      end

      def color_for_line(line)
        case line[0].chr
        when "+"
          :green
        when "-"
          :red
        when "@"
          line[1].chr == "@" ? :blue : nil
        else
          nil
        end
      end

      def pretty_json(data)
        FFI_Yajl::Encoder.encode(data, pretty: true).lines.map(&:chomp)
      end

      def detect_cookbook_changes
        all_locked_cookbooks = old_cookbook_locks.keys | new_cookbook_locks.keys

        @added_cookbooks = []
        @removed_cookbooks = []
        @modified_cookbooks = []

        all_locked_cookbooks.each do |cb_name|
          if old_cookbook_locks.key?(cb_name) && new_cookbook_locks.key?(cb_name)
            old_cb_lock = old_cookbook_locks[cb_name]
            new_cb_lock = new_cookbook_locks[cb_name]
            if old_cb_lock != new_cb_lock
              @modified_cookbooks << cb_name
            end
          elsif old_cookbook_locks.key?(cb_name)
            @removed_cookbooks << cb_name
          elsif new_cookbook_locks.key?(cb_name)
            @added_cookbooks << cb_name
          else
            raise "Bug: cookbook lock #{cb_name} cannot be determined as new/removed/modified/unmodified"
          end
        end
      end

    end
  end
end