# frozen_string_literal: true module Doing ## ## Log adapter ## class LogAdapter # Sets the log device attr_writer :logdev # Max length of log messages (truncate in middle) attr_writer :max_length # Returns the current log level (debug, info, warn, error) attr_reader :level attr_reader :messages, :results TOPIC_WIDTH = 12 LOG_LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO, warn: ::Logger::WARN, error: ::Logger::ERROR }.freeze COUNT_KEYS = %i[ added added_tags archived autotag completed completed_archived deleted moved removed_tags rotated skipped updated ].freeze # # Create a new instance of a log writer # # @param level (optional, symbol) the log level # def initialize(level = :info) @messages = [] @counters = {} COUNT_KEYS.each { |key| @counters[key] = { tag: [], count: 0 } } @results = [] @logdev = $stderr @max_length = `tput cols`.strip.to_i - 5 || 85 self.log_level = level @prev_level = level end # # Set the log level on the writer # # @param level (symbol) the log level # # @return nothing # def log_level=(level = 'info') level = level.to_s level = case level when /^[e0]/i :error when /^[w1]/i :warn when /^[d3]/i :debug else :info end @level = level end # Set log level temporarily def temp_level(level) return if level.nil? || level.to_sym == @log_level @prev_level = log_level.dup @log_level = level.to_sym end # Restore temporary level def restore_level return if @prev_level.nil? || @prev_level == @log_level self.log_level = @prev_level @prev_level = nil end def adjust_verbosity(options = {}) if options[:log_level] self.log_level = options[:log_level].to_sym elsif options[:quiet] self.log_level = :error elsif options[:verbose] || options[:debug] self.log_level = :debug end log_now :debug, 'Logging at level:', @level.to_s # log_now :debug, 'Doing Version:', Doing::VERSION end def count(key, level: :info, count: 1, tag: nil, message: nil) raise ArgumentError, 'invalid counter key' unless COUNT_KEYS.include?(key) @counters[key][:count] += count @counters[key][:tag].concat(tag).sort.uniq unless tag.nil? @counters[key][:level] ||= level @counters[key][:message] ||= message end # # Print a debug message # # @param topic the topic of the message # @param message the message detail # # @return nothing # def debug(topic, message = nil, &block) write(:debug, topic, message, &block) end # # Print a message # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param message the message detail # # @return nothing # def info(topic, message = nil, &block) write(:info, topic, message, &block) end # # Print a message # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param message the message detail # # @return nothing # def warn(topic, message = nil, &block) write(:warn, topic, message, &block) end # # Print an error message # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param message the message detail # # @return nothing # def error(topic, message = nil, &block) write(:error, topic, message, &block) end # # Print an error message and immediately # abort the process # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param message the message detail (can be # omitted) # # @return nothing # def abort_with(topic, message = nil, &block) error(topic, message, &block) abort end # # Format the topic # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param colon Separate with a colon? # # @return the formatted topic statement # def formatted_topic(topic, colon: false) if colon "#{topic}: ".rjust(TOPIC_WIDTH) elsif topic =~ /:$/ "#{topic} ".rjust(TOPIC_WIDTH) else "#{topic} " end end # # Log a message. # # @param level_of_message [Symbol] the Symbol # level of message, one of # :debug, :info, :warn, # :error # @param topic [String] the String # topic or full message # @param message [String] the String # message (optional) # @param block a block containing the # message (optional) # # @return [Boolean] false if the message was not written # def write(level_of_message, topic, message = nil, &block) @results << { level: level_of_message, message: message(topic, message, &block) } true end ## ## Log to console immediately instead of writing messages on exit ## ## @param level [Symbol] The level ## @param topic [String] The topic or full message ## @param message [String] The message (optional) ## @param block a block containing the message (optional) ## def log_now(level, topic, message = nil, &block) return false unless write_message?(level) if @logdev == $stdout @logdev.puts message(topic, message, &block) else @logdev.puts color_message(level, topic, message, &block) end end ## ## Output registers based on log level ## ## @return nothing ## def output_results total_counters results = @results.select { |msg| write_message?(msg[:level]) }.uniq if @logdev == $stdout $stdout.print results.map {|res| res[:message].uncolor }.join("\n") else results.each do |msg| @logdev.puts color_message(msg[:level], msg[:message]) end end end def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false) if tags_added.empty? && tags_removed.empty? count(:skipped, level: :debug, message: '%count %items with no change', count: count) else if tags_added.empty? count(:skipped, level: :debug, message: 'no tags added to %count %items') elsif single && item added = tags_added.log_tags info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{added} to #{item.title})) else count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items') end if tags_removed.empty? count(:skipped, level: :debug, message: 'no tags removed from %count %items') elsif single && item added = tags_added.log_tags info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{added} from #{item.title})) else count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items') end end end private def format_counter(key, data) case key when :rotated ['Rotated:', data[:message] || 'rotated %count %items'] when :autotag ['Autotag:', data[:message] || 'autotagged %count %items'] when :added_tags ['Tagged:', data[:message] || 'added %tags to %count %items'] when :removed_tags ['Untagged:', data[:message] || 'removed %tags from %count %items'] when :added ['Added:', data[:message] || 'added %count new %items'] when :updated ['Updated:', data[:message] || 'updated %count %items'] when :deleted ['Deleted:', data[:message] || 'deleted %count %items'] when :moved ['Moved:', data[:message] || 'moved %count %items'] when :completed ['Completed:', data[:message] || 'completed %count %items'] when :archived ['Archived:', data[:message] || 'archived %count %items'] when :completed_archived ['Archived:', data[:message] || 'completed and archived %count %items'] when :skipped ['Skipped:', data[:message] || '%count %items were unchanged'] end end def total_counters @counters.each do |key, data| next if data[:count].zero? count = data[:count] tags = data[:tag] ? data[:tag].uniq.map { |t| "@#{t}".cyan }.join(', ') : 'tags' topic, m = format_counter(key, data) message = m.dup message.sub!(/%count/, count.to_s) message.sub!(/%items/, count == 1 ? 'item' : 'items') message.sub!(/%tags/, tags) write(data[:level], topic, message) end end # # Check if the message should be written # given the log level. # # @param level_of_message the Symbol level of # message, one of :debug, # :info, :warn, :error # # @return whether the message should be written. # def write_message?(level_of_message) LOG_LEVELS.fetch(@level) <= LOG_LEVELS.fetch(level_of_message) end # Internal: Build a topic method # # @param topic the topic of the message, e.g. # "Configuration file", # "Deprecation", etc. # @param message the message detail # # @return the formatted message # def message(topic, message = nil) raise ArgumentError, 'block or message, not both' if block_given? && message message = yield if block_given? message = message.to_s.gsub(/\s+/, ' ') return topic.ljust(TOPIC_WIDTH) if topic && message.strip.empty? topic = formatted_topic(topic, colon: block_given?) message.truncmiddle!(@max_length - TOPIC_WIDTH - 5) out = topic + message out.truncate!(@max_length) if @max_length.positive? messages << out out end def color_message(level, topic, message = nil, &block) colors = Doing::Color message = message(topic, message, &block) prefix = ' ' topic_fg = colors.boldcyan message_fg = colors.boldwhite case level when :debug prefix = '> '.softpurple topic_fg = colors.softpurple message_fg = colors.white when :warn prefix = '> '.boldyellow topic_fg = colors.boldyellow message_fg = colors.yellow when :error prefix = '!!'.boldred topic_fg = colors.flamingo message_fg = colors.red end message.sub!(/^(\s*\S.*?): (.*?)$/) do m = Regexp.last_match msg = m[2] =~ /(\e\[[\d;]+m)/ ? msg : "#{message_fg}#{m[2]}" "#{topic_fg}#{m[1]}#{colors.reset}: #{message_fg}#{m[2]}" end "#{prefix} #{message.highlight_tags}#{colors.reset}" end end end