lib/covered/persist.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2025, by Samuel Williams.
# Copyright, 2023, by Stephen Ierodiaconou.

require_relative "wrapper"

require "msgpack"
require "time"

module Covered
	# Persists coverage records to a MessagePack database.
	class Persist < Wrapper
		DEFAULT_PATH = ".covered.db"
		
		# Initialize persistence for the given output and database path.
		# @parameter output [Covered::Base] The output to wrap.
		# @parameter path [String] The coverage database path.
		def initialize(output, path = DEFAULT_PATH)
			super(output)
			
			@path = self.expand_path(path)
		end
		
		# Apply a persisted record to the output.
		# Records with stale source modification times are ignored unless `ignore_mtime` is true.
		# @parameter record [Hash] The persisted coverage record.
		# @parameter ignore_mtime [Boolean] Whether to apply records even if their source appears stale.
		# @returns [Boolean] Whether the record was applied.
		def apply(record, ignore_mtime: false)
			if coverage = record[:coverage]
				if path = record[:path]
					path = self.expand_path(path)
					coverage.path = path
				end
				
				if ignore_mtime || coverage.fresh?
					add(coverage)
					return true
				end
			end
			
			return false
		end
		
		# Convert coverage into a database record.
		# @parameter coverage [Covered::Coverage] The coverage object to serialize.
		# @returns [Hash] A MessagePack-compatible record.
		def serialize(coverage)
			{
				# We want to use relative paths so that moving the repo won't break everything:
				pid: Process.pid,
				path: relative_path(coverage.path),
				# relative_path: relative_path(coverage.path),
				coverage: coverage,
			}
		end
		
		# Load persisted coverage records into the output.
		# @parameter options [Hash] Options forwarded to {apply}.
		# @raises [LoadError] If the database exists but cannot be decoded.
		def load!(**options)
			return unless File.exist?(@path)
			
			# Load existing coverage information and mark all files:
			File.open(@path, "rb") do |file|
				file.flock(File::LOCK_SH)
				
				make_unpacker(file).each do |record|
					# pp load: record
					self.apply(record, **options)
				end
			end
		rescue
			raise LoadError, "Failed to load coverage from #{@path}, maybe old format or corrupt!"
		end
		
		# Save all output coverage records to the database.
		def save!
			# Dump all coverage:
			File.open(@path, "ab") do |file|
				file.flock(File::LOCK_EX)
				
				packer = make_packer(file)
				
				@output.each do |coverage|
					# pp save: coverage
					packer.write(serialize(coverage))
				end
				
				packer.flush
			end
		end
		
		# Finish the wrapped output and save the coverage database.
		def finish
			super
			
			self.save!
		end
		
		# Reload persisted coverage and enumerate the wrapped output.
		# @yields {|coverage| ...} Each coverage object from the reloaded output.
		# 	@parameter coverage [Covered::Coverage] The current coverage object.
		# @returns [Enumerator | Nil] An enumerator without a block.
		def each(&block)
			return to_enum unless block_given?
			
			@output.clear
			self.load!
			
			super
		end
		
		# Build the MessagePack factory used for coverage records.
		# @returns [MessagePack::Factory] The configured MessagePack factory.
		def make_factory
			factory = MessagePack::Factory.new
			
			factory.register_type(0x00, Symbol)
			
			factory.register_type(0x01, Time,
				packer: MessagePack::Time::Packer,
				unpacker: MessagePack::Time::Unpacker
			)
			
			factory.register_type(0x20, Source,
				recursive: true,
				packer: :serialize,
				unpacker: :deserialize,
			)
			
			factory.register_type(0x21, Coverage,
				recursive: true,
				packer: :serialize,
				unpacker: :deserialize,
			)
			
			return factory
		end
		
		# Build a MessagePack packer for the given IO.
		# @parameter io [IO] The IO to write packed records to.
		# @returns [MessagePack::Packer] A packer configured for coverage records.
		def make_packer(io)
			return make_factory.packer(io)
		end
		
		# Build a MessagePack unpacker for the given IO.
		# @parameter io [IO] The IO to read packed records from.
		# @returns [MessagePack::Unpacker] An unpacker configured for coverage records.
		def make_unpacker(io)
			return make_factory.unpacker(io)
		end
	end
end