desc “Generate security audit and code quality report” # e.g.: rake code_quality lowest_score=90 max_offenses=100 metrics=stats,rails_best_practices,roodi rails_best_practices_max_offenses=10 roodi_max_offenses=10 task :code_quality => :“code_quality:default” do; end if Rake.application.instance_of?(Rake::Application) namespace :code_quality do

task :default => [:summary, :security_audit, :quality_audit, :generate_index] do; end

# desc "show summary"
task :summary do
  puts "# Code Quality Report", "\n"
  puts "Generated by code_quality (v#{CodeQuality::VERSION}) @ #{Time.now}", "\n"
end

# desc "generate a report index page"
task :generate_index => :helpers do
  index_path = "tmp/code_quality/index.html"
  generate_index index_path
  # puts "Generate report index to #{index_path}"
  show_in_browser File.realpath(index_path)
end

desc "security audit using bundler-audit, brakeman"
task :security_audit => [:"security_audit:default"] do; end
namespace :security_audit do
  # default tasks
  task :default => [:bundler_audit, :brakeman, :resources] do; end

  # desc "prepare dir"
  task :prepare => :helpers do
    @report_dir = "tmp/code_quality/security_audit"
    prepare_dir @report_dir

    def report_dir
      @report_dir
    end
  end

  desc "bundler audit"
  # Update the ruby-advisory-db and check Gemfile.lock
  # options:
  #   bundler_audit_options: pass extract CLI options, e.g.: bundler_audit_options="--ignore CVE-2020-5267 CVE-2020-10663"
  task :bundler_audit => :prepare do |task|
    options = options_from_env(:bundler_audit_options)
    run_audit task, "bundler audit - checks for vulnerable versions of gems in Gemfile.lock" do
      report = `bundle audit check --update #{options[:bundler_audit_options]}`
      @report_path = "#{report_dir}/bundler-audit-report.txt"
      File.open(@report_path, 'w') {|f| f.write report }
      puts report
      audit_faild "Must fix vulnerabilities ASAP" unless report =~ /No vulnerabilities found/
    end
  end

  desc "brakeman"
  # options:
  #   brakeman_options: pass extract CLI options, e.g.: brakeman_options="--skip-files lib/templates/"
  task :brakeman => :prepare do |task|
    options = options_from_env(:brakeman_options)
    require 'json'
    run_audit task, "Brakeman audit - checks Ruby on Rails applications for security vulnerabilities" do
      @report_path = "#{report_dir}/brakeman-report.txt"
      `brakeman -o #{@report_path} -o #{report_dir}/brakeman-report.json #{options[:brakeman_options]} .`
      puts `cat #{@report_path}`
      report = JSON.parse(File.read("#{report_dir}/brakeman-report.json"))
      audit_faild "There are #{report["errors"].size} errors, must fix them ASAP." if report["errors"].any?
    end
  end

  # desc "resources url"
  task :resources do
    refs = %w{
      https://github.com/presidentbeef/brakeman
      https://github.com/rubysec/bundler-audit
      http://guides.rubyonrails.org/security.html
      https://github.com/hardhatdigital/rails-security-audit
      https://hakiri.io/blog/ruby-security-tools-and-resources
      https://www.netsparker.com/blog/web-security/ruby-on-rails-security-basics/
      https://www.owasp.org/index.php/Ruby_on_Rails_Cheatsheet
    }
    puts "## Security Resources"
    puts refs.map { |url| "  - #{url}" }, "\n"
  end
end

desc "code quality audit"
# e.g.: rake code_quality:quality_audit fail_fast=true
# options:
#   fail_fast: to stop immediately if any audit task fails, by default fail_fast=false
#   generate_index: generate a report index page to tmp/code_quality/quality_audit/index.html, by default generate_index=false
task :quality_audit => [:"quality_audit:default"] do; end
namespace :quality_audit do |ns|
  # default tasks
  task :default => [:run_all, :resources] do; end

  # desc "run all audit tasks"
  task :run_all => :helpers do
    options = options_from_env(:fail_fast, :generate_index)
    fail_fast = options.fetch(:fail_fast, "false")
    generate_index = options.fetch(:generate_index, "false")
    audit_tasks = [:rubycritic, :rubocop, :metric_fu]
    exc = nil
    audit_tasks.each do |task_name|
      begin
        task = ns[task_name]
        task.invoke
      rescue SystemExit => exc
        raise exc if fail_fast == "true"
      end
    end

    # generate a report index page to tmp/code_quality/quality_audit/index.html
    if options[:generate_index] == "true"
      index_path = "tmp/code_quality/quality_audit/index.html"
      @audit_tasks.each do |task_name, report|
        report[:report_path].sub!("quality_audit/", "")
      end
      generate_index index_path
      puts "Generate report index to #{index_path}"
    end

    audit_faild "" if exc
  end

  # desc "prepare dir"
  task :prepare => :helpers do
    @report_dir = "tmp/code_quality/quality_audit"
    prepare_dir @report_dir

    def report_dir
      @report_dir
    end
  end

  desc "rubycritic"
  # e.g.: rake code_quality:quality_audit:rubycritic lowest_score=94.5
  task :rubycritic => :prepare do |task|
    options = options_from_env(:lowest_score)
    run_audit task, "Rubycritic - static analysis gems such as Reek, Flay and Flog to provide a quality report of your Ruby code." do
      report = `rubycritic -p #{report_dir}/rubycritic app lib --no-browser`
      puts report
      @report_path = report_path = "#{report_dir}/rubycritic/overview.html"
      show_in_browser File.realpath(report_path)

      # if config lowest_score then audit it with report score
      if options[:lowest_score]
        if report[-20..-1] =~ /Score: (.+)/
          report_score = $1.to_f
          lowest_score = options[:lowest_score].to_f
          audit_faild "Report score #{colorize(report_score, :yellow)} is lower then #{colorize(lowest_score, :yellow)}, must improve your code quality or set a higher #{colorize("lowest_score", :black, :white)}" if report_score < lowest_score
        end
      end
    end
  end

  desc "rubocop - audit coding style"
  # e.g.: rake code_quality:quality_audit:rubocop rubocop_max_offenses=100
  # options:
  #   config_formula: use which formula for config, supports "github, "rails" or path_to_your_local_config.yml, default is "github"
  #   cli_options: pass extract options, e.g.: cli_options="--show-cops"
  #   rubocop_max_offenses: if config rubocop_max_offenses then audit it with detected offenses number in report, e.g.: rubocop_max_offenses=100
  task :rubocop => :prepare do |task|
    run_audit task, "rubocop - RuboCop is a Ruby static code analyzer. Out of the box it will enforce many of the guidelines outlined in the community Ruby Style Guide." do
      options = options_from_env(:config_formula, :cli_options, :rubocop_max_offenses)

      config_formulas = {
        'github' => 'https://github.com/github/rubocop-github',
        'rails' => 'https://github.com/rails/rails/blob/master/.rubocop.yml'
      }

      # prepare cli options
      config_formula = options.fetch(:config_formula, 'github')
      if config_formula && File.exists?(config_formula)
        config_file = config_formula
        puts "Using config file: #{config_file}"
      else
        gem_config_dir = File.expand_path("../../../config", __FILE__)
        config_file    = "#{gem_config_dir}/rubocop-#{config_formula}.yml"
        puts "Using config formula: [#{config_formula}](#{config_formulas[config_formula]})"
      end
      @report_path = report_path = "#{report_dir}/rubocop-report.html"

      # generate report
      report = `rubocop -c #{config_file} -S -R -P #{options[:cli_options]} --format offenses --format html -o #{report_path}`
      puts report
      puts "Generated by RuboCop #{`rubocop --version`}"
      puts "Report generated to #{report_path}"
      show_in_browser File.realpath(report_path)

      # if config rubocop_max_offenses then audit it with detected offenses number in report
      if options[:rubocop_max_offenses]
        if report[-20..-1] =~ /(\d+) *Total/
          detected_offenses = $1.to_i
          max_offenses = options[:rubocop_max_offenses].to_i
          audit_faild "Detected offenses #{colorize(detected_offenses, :yellow)} is more then #{colorize(max_offenses, :yellow)}, must improve your code quality or set a lower #{colorize("rubocop_max_offenses", :black, :white)}" if detected_offenses > max_offenses
        end
      end
    end
  end

  desc "metric_fu - many kinds of metrics"
  # e.g.: rake code_quality:quality_audit:metric_fu metrics=stats,rails_best_practices,roodi rails_best_practices_max_offenses=9 roodi_max_offenses=10
  # options:
  #   metrics: default to run all metrics, can be config as: cane,churn,flay,flog,hotspots,rails_best_practices,rcov,reek,roodi,saikuro,stats
  #   flay_max_offenses: offenses number for audit
  #   cane_max_offenses: offenses number for audit
  #   rails_best_practices_max_offenses: offenses number for audit
  #   reek_max_offenses: offenses number for audit
  #   roodi_max_offenses: offenses number for audit
  task :metric_fu => :prepare do |task|
    metrics_offenses_patterns = {
      "flay" => /Total Score (\d+)/,
      "cane" => /Total Violations (\d+)/,
      "rails_best_practices" => /Found (\d+) errors/,
      "reek" => /Found (\d+) code smells/,
      "roodi" => /Found (\d+) errors/,
    }
    metrics_have_offenses = metrics_offenses_patterns.keys.map { |metric| "#{metric}_max_offenses".to_sym }
    options = options_from_env(:metrics, *metrics_have_offenses)
    run_audit task, "metric_fu - Code metrics from Flog, Flay, Saikuro, Churn, Reek, Roodi, Code Statistics, and Rails Best Practices. (and optionally RCov)" do
      report_path = "#{report_dir}/metric_fu"
      available_metrics = %w{cane churn flay flog hotspots rails_best_practices rcov reek roodi saikuro stats}
      metric_fu_opts = ""
      selected_metrics = available_metrics
      if options[:metrics]
        selected_metrics = options[:metrics].split(",")
        disable_metrics = available_metrics - selected_metrics
        selected_metrics_opt = selected_metrics.map { |m| "--#{m}" }.join(" ")
        disable_metrics_opt = disable_metrics.map { |m| "--no-#{m}" }.join(" ")
        metric_fu_opts = "#{selected_metrics_opt} #{disable_metrics_opt}"
        puts "for metrics: #{selected_metrics.join(",")}"
      end
      # geneate report
      report = `metric_fu --no-open #{metric_fu_opts}`
      FileUtils.remove_dir(report_path) if Dir.exists? report_path
      FileUtils.mv("tmp/metric_fu/output", report_path, force: true)
      puts report
      puts "Report generated to #{report_path}"
      show_in_browser File.realpath(report_path)
      @report_path = "#{report_path}/index.html"

      # audit report result
      report_result_path = "tmp/metric_fu/report.yml"
      if File.exists? report_result_path
        require 'yaml'
        report_result = YAML.load_file(report_result_path)
        # if config #{metric}_max_offenses then audit it with report result
        audit_failures = []
        metrics_offenses_patterns.each do |metric, pattern|
          option_key = "#{metric}_max_offenses".to_sym
          if options[option_key]
            detected_offenses = report_result[metric.to_sym][:total].to_s.match(pattern)[1].to_i rescue 0
            max_offenses = options[option_key].to_i
            if detected_offenses > max_offenses
              puts "Metric #{colorize(metric, :green)} detected offenses #{colorize(detected_offenses, :yellow)} is more then #{colorize(max_offenses, :yellow)}, must improve your code quality or set a lower #{colorize(option_key, :black, :white)}"
              audit_failures << {metric: metric, detected_offenses: detected_offenses, max_offenses: max_offenses}
            end
          end
        end
        audit_faild "#{audit_failures.size} of #{selected_metrics.size} metrics audit failed" if audit_failures.any?
      end
    end
  end

  # desc "resources url"
  task :resources do
    refs = %w{
      http://awesome-ruby.com/#-code-analysis-and-metrics
      https://github.com/whitesmith/rubycritic
      https://github.com/bbatsov/rubocop
      https://github.com/bbatsov/ruby-style-guide
      https://github.com/github/rubocop-github
      https://github.com/metricfu/metric_fu
      https://rails-bestpractices.com
    }
    puts "## Code Quality Resources"
    puts refs.map { |url| "  - #{url}" }
  end
end

# desc "helper methods"
task :helpers do
  def run_audit(task, title, &block)
    task_name = task.name.split(":").last
    @audit_tasks ||= {}
    @audit_tasks[task_name] ||= {
      report_path: "",
      failure: "",
    }
    puts "## #{title}"
    puts "", "```"
    exc = nil
    begin
      realtime(&block)
    rescue SystemExit => exc
      # audit faild
      @audit_tasks[task_name][:failure] = exc.message.gsub(/(\e\[\d+m)/, "")
    ensure
      # get @report_path set in each audit task
      @audit_tasks[task_name][:report_path] = @report_path&.sub("tmp/code_quality/", "")
    end
    puts "```", ""
    raise exc if exc
  end

  def realtime(&block)
    require 'benchmark'
    realtime = Benchmark.realtime do
      block.call
    end.round
    process_time = humanize_secs(realtime)
    puts "[ #{process_time} ]"
  end

  # p humanize_secs 60
  # => 1m
  # p humanize_secs 1234
  #=>"20m 34s"
  def humanize_secs(secs)
    [[60, :s], [60, :m], [24, :h], [1000, :d]].map{ |count, name|
      if secs > 0
        secs, n = secs.divmod(count)
        "#{n.to_i}#{name}"
      end
    }.compact.reverse.join(' ').chomp(' 0s')
  end

  def prepare_dir(dir)
    FileUtils.mkdir_p dir
  end

  def audit_faild(msg)
    flag = colorize("[AUDIT FAILED]", :red, :yellow)
    abort "#{flag} #{msg}"
  end

  # e.g.: options_from_env(:a, :b) => {:a => ..., :b => ... }
  def options_from_env(*keys)
    # ENV.to_h.slice(*keys.map(&:to_s)).symbolize_keys! # using ActiveSupport
    ENV.to_h.inject({}) { |opts, (k, v)| keys.include?(k.to_sym) ? opts.merge({k.to_sym => v}) : opts }
  end

  # set text color, background color using ANSI escape sequences, e.g.:
  #   colors = %w(black red green yellow blue pink cyan white default)
  #   colors.each { |color| puts colorize(color, color) }
  #   colors.each { |color| puts colorize(color, :green, color) }
  def colorize(text, color = "default", bg = "default")
    colors = %w(black red green yellow blue pink cyan white default)
    fgcode = 30; bgcode = 40
    tpl = "\e[%{code}m%{text}\e[0m"
    cov = lambda { |txt, col, cod| tpl % {text: txt, code: (cod+colors.index(col.to_s))} }
    ansi = cov.call(text, color, fgcode)
    ansi = cov.call(ansi, bg, bgcode) if bg.to_s != "default"
    ansi
  end

  def show_in_browser(dir)
    require "launchy"
    require "uri"
    uri = URI.escape("file://#{dir}/")
    if File.directory?(dir)
      uri = URI.join(uri, "index.html")
    end
    Launchy.open(uri) if open_in_browser?
  end

  def open_in_browser?
    ENV["CI"].nil?
  end

  def generate_index(index_path)
    require "erb"
    prepare_dir "tmp/code_quality"
    gem_app_dir = File.expand_path("../../../app", __FILE__)
    erb_file = "#{gem_app_dir}/views/code_quality/index.html.erb"

    # render view
    @audit_tasks ||= []
    erb = ERB.new(File.read(erb_file))
    output = erb.result(binding)

    File.open(index_path, 'w') {|f| f.write output }
  end
end

end