lib/covered/config.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2026, by Samuel Williams.

require_relative "policy"

module Covered
	# Loads project coverage configuration and controls a configured policy.
	class Config
		PATH = "config/covered.rb"
		
		# The root directory used for coverage configuration.
		# @returns [String] The value of `COVERED_ROOT`, or the current working directory.
		def self.root
			ENV["COVERED_ROOT"] || Dir.pwd
		end
		
		# The coverage configuration path under the given root.
		# @parameter root [String] The project root.
		# @returns [String | Nil] The expanded configuration path if it exists.
		def self.path(root)
			path = ::File.expand_path(PATH, root)
			
			if ::File.exist?(path)
				return path
			end
		end
		
		# The report names requested by the environment.
		# @returns [String | Nil] The `COVERAGE` environment value.
		def self.reports
			ENV["COVERAGE"]
		end
		
		# Load the project coverage configuration for the given root.
		# @parameter root [String] The project root.
		# @parameter reports [String | Boolean | Array | Object | Nil] The report configuration.
		# @returns [Covered::Config] The loaded configuration instance.
		def self.load(root: self.root, reports: self.reports)
			derived = Class.new(self)
			
			if path = self.path(root)
				config = Module.new
				config.module_eval(::File.read(path), path)
				derived.prepend(config)
			end
			
			return derived.new(root, reports)
		end
		
		# Initialize the configuration for a project root and reports.
		# @parameter root [String] The project root.
		# @parameter reports [String | Boolean | Array | Object | Nil] The report configuration.
		def initialize(root, reports)
			@root = root
			@reports = reports
			@policy = nil
			
			@environment = nil
		end
		
		# Whether reports should be generated.
		# @returns [Boolean] Whether reporting is enabled.
		def report?
			!!@reports
		end
		
		alias :record? :report?
		
		# @attribute [Covered::Policy | Nil] The active coverage policy, if assigned by an integration.
		attr :coverage
		
		# The configured coverage policy.
		# @returns [Covered::Policy] The memoized, frozen policy.
		def policy
			@policy ||= Policy.new.tap{|policy| make_policy(policy)}.freeze
		end
		
		# The configured policy output wrapper.
		# @returns [Covered::Base] The output wrapper at the end of the policy pipeline.
		def output
			policy.output
		end
		
		# Start coverage tracking.
		# Stores the current environment, configures child process autostart, and starts the policy capture pipeline.
		def start
			# Save and setup the environment:
			@environment = ENV.to_h
			autostart!
			
			# Start coverage tracking:
			policy.start
		end
		
		# Finish coverage tracking.
		# Stops the policy capture pipeline and restores the environment saved by {start}.
		def finish
			# Finish coverage tracking:
			policy.finish
			
			# Restore the environment:
			ENV.replace(@environment)
			@environment = nil
		end
		
		# Generate coverage reports to the given output.
		# @parameter output [IO] The output stream to write the coverage report to.
		def call(output)
			policy.call(output)
		end
		
		# Enumerate the coverage data from the configured policy.
		# @yields {|coverage| ...} Each coverage object from the policy.
		# 	@parameter coverage [Covered::Coverage] The current coverage object.
		def each(&block)
			policy.each(&block)
		end
		
		# Build a configured policy using coverage data from persistent storage.
		#
		# @parameter paths [Array(String)] The coverage database paths.
		# @parameter ignore_mtime [Boolean] Whether to ignore source file modification times.
		# @returns [Covered::Policy] The configured policy with loaded coverage data.
		def policy_for(paths = nil, ignore_mtime: true)
			paths ||= Dir.glob(Persist::DEFAULT_PATH, base: @root)
			paths = Array(paths)
			
			if paths.empty?
				raise ArgumentError, "No coverage paths specified!"
			end
			
			paths.each do |path|
				# It would be nice to have a better algorithm here than just ignoring mtime - perhaps using checksums:
				Persist.new(policy.output, path).load!(ignore_mtime: ignore_mtime)
			end
			
			return policy
		end
		
		# Which paths to ignore when computing coverage for a given project.
		# @returns [Array(String)] An array of relative paths to ignore.
		def ignore_paths
			["test/", "fixtures/", "spec/", "vendor/", "config/"]
		end
		
		# Which paths to include when computing coverage for a given project.
		# @returns [Array(String)] An array of relative patterns to include, e.g. `"lib/**/*.rb"`.
		def include_patterns
			["lib/**/*.rb"]
		end
		
		# Override this method to implement your own policy.
		# @parameter policy [Covered::Policy] The policy to configure.
		def make_policy(policy)
			# Only files in the root would be tracked:
			policy.root(@root)
			
			patterns = ignore_paths.map do |path|
				File.join(@root, path)
			end
			
			# We will ignore any files in the test or spec directory:
			policy.skip(Regexp.union(patterns))
			
			# We will include all files under lib, even if they aren't loaded:
			include_patterns.each do |pattern|
				policy.include(pattern)
			end
			
			policy.persist!
			
			policy.reports!(@reports)
		end
		
		protected
		
		REQUIRE_COVERED_AUTOSTART = "-rcovered/autostart"
		
		def autostart!
			if rubyopt = ENV["RUBYOPT"] and !rubyopt.empty?
				rubyopt = [rubyopt.strip, REQUIRE_COVERED_AUTOSTART].join(" ")
			else
				rubyopt = REQUIRE_COVERED_AUTOSTART
			end
			
			ENV["RUBYOPT"] = rubyopt
			
			unless ENV["COVERED_ROOT"]
				ENV["COVERED_ROOT"] = @root
			end
			
			# Don't report coverage in child processes:
			ENV.delete("COVERAGE")
		end
	end
end