{-# LANGUAGE QuasiQuotes #-} {- | Module : HCovGuard.Checks.CoverageCheck Description : Functions and Types around checking coverage against thresholds. Copyright : (c) Trevis Elser, 2026 License : MIT Maintainer : oss@treviselser.com Here the what should be meets the reality, resulting in failures that will be shown to users. -} module HCovGuard.Checks.CoverageCheck ( CheckResult (..) , CheckFailure (..) , CheckResultWithMatch (..) , checkModuleCoverage , isFailure , showCheckFailure , showGroupedFailures ) where import qualified Data.List.NonEmpty as NonEmpty import qualified Data.Maybe as Maybe import qualified Data.String.Interpolate as Interpolate import qualified Data.Text as Text import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Builder as Builder import qualified Data.Text.Lazy.Builder.Int as Builder import qualified HCovGuard.Checks.ThresholdCheck as ThresholdCheck import qualified HCovGuard.Config as Config import qualified HCovGuard.Coverage as Coverage data CheckResult = CheckPassed | CheckFailed (NonEmpty.NonEmpty CheckFailure) | CheckIgnored -- | Result of checking module coverage, including which pattern matched (if any) data CheckResultWithMatch = CheckResultWithMatch { checkResult :: !CheckResult , matchedPatternIndex :: !(Maybe Int) -- ^ The 0-based index of the matched forSpecifiedModules entry, if any } checkModuleCoverage :: Config.Config -> Text.Text -> Coverage.ModuleCoverage -> CheckResultWithMatch checkModuleCoverage config modName modCov = let (thresholds, matchIdx) = determineThresholdsWithIndex config modName in CheckResultWithMatch { checkResult = checkModuleCoverageForThresholds thresholds modCov , matchedPatternIndex = matchIdx } {- | Note that the categories here mirror the 'Box' labels used by HPC. This is not available in as straightforward of a way from HPC itself. -} data CoverageCategory = ExpressionCoverage | TopLevelCoverage | AlternativeCoverage | LocalCoverage showCategory :: CoverageCategory -> Text.Text showCategory ExpressionCoverage = [Interpolate.i|expression|] showCategory TopLevelCoverage = [Interpolate.i|top-level|] showCategory AlternativeCoverage = [Interpolate.i|alternative|] showCategory LocalCoverage = [Interpolate.i|local|] data ModuleThresholds = ModuleThresholds { thresholdExpression :: !Config.CoverageThreshold , thresholdTopLevel :: !Config.CoverageThreshold , thresholdAlternative :: !Config.CoverageThreshold , thresholdLocal :: !Config.CoverageThreshold , thresholdIgnore :: !Bool } data CheckFailure = CheckFailure { failureModule :: !Text.Text , failureCategory :: !CoverageCategory , failureDetail :: !ThresholdCheck.CoverageFailure } determineThresholdsWithIndex :: Config.Config -> Text.Text -> (ModuleThresholds, Maybe Int) determineThresholdsWithIndex config modName = let anyMod = Config.forAnyModule config maybeSpecified = Config.moduleMatchesPatternWithIndex modName (Config.forSpecifiedModules config) in case maybeSpecified of Just (idx, specified) | Config.specifiedIgnore specified -> ( ModuleThresholds { thresholdExpression = Config.CoverageThreshold Nothing Nothing , thresholdTopLevel = Config.CoverageThreshold Nothing Nothing , thresholdAlternative = Config.CoverageThreshold Nothing Nothing , thresholdLocal = Config.CoverageThreshold Nothing Nothing , thresholdIgnore = True } , Just idx ) Just (idx, specified) -> ( ModuleThresholds { thresholdExpression = Maybe.fromMaybe (Config.expressionThreshold anyMod) (Config.specifiedExpressionThreshold specified) , thresholdTopLevel = Maybe.fromMaybe (Config.topLevelThreshold anyMod) (Config.specifiedTopLevelThreshold specified) , thresholdAlternative = Maybe.fromMaybe (Config.alternativeThreshold anyMod) (Config.specifiedAlternativeThreshold specified) , thresholdLocal = Maybe.fromMaybe (Config.localThreshold anyMod) (Config.specifiedLocalThreshold specified) , thresholdIgnore = False } , Just idx ) Nothing -> ( ModuleThresholds { thresholdExpression = Config.expressionThreshold anyMod , thresholdTopLevel = Config.topLevelThreshold anyMod , thresholdAlternative = Config.alternativeThreshold anyMod , thresholdLocal = Config.localThreshold anyMod , thresholdIgnore = False } , Nothing ) checkModuleCoverageForThresholds :: ModuleThresholds -> Coverage.ModuleCoverage -> CheckResult checkModuleCoverageForThresholds thresholds modCov | thresholdIgnore thresholds = CheckIgnored | otherwise = let modName = Coverage.moduleName modCov stats = Coverage.moduleStats modCov failures = concat [ checkCategory modName ExpressionCoverage (thresholdExpression thresholds) (Coverage.expressionCoverage stats) , checkCategory modName TopLevelCoverage (thresholdTopLevel thresholds) (Coverage.topLevelCoverage stats) , checkCategory modName AlternativeCoverage (thresholdAlternative thresholds) (Coverage.alternativeCoverage stats) , checkCategory modName LocalCoverage (thresholdLocal thresholds) (Coverage.localCoverage stats) ] in maybe CheckPassed CheckFailed (NonEmpty.nonEmpty failures) checkCategory :: Text.Text -> CoverageCategory -> Config.CoverageThreshold -> Coverage.CoverageCounts -> [CheckFailure] checkCategory modName category threshold counts = let total = Coverage.countsTotal counts buildCheckFailure = CheckFailure modName category in fmap buildCheckFailure . ThresholdCheck.checkCoverage threshold total $ Coverage.countsCovered counts -- | Check if a result represents a failure. isFailure :: CheckResult -> Bool isFailure (CheckFailed _) = True isFailure _ = False {- | Format a single check failure without the module name header. @since 0.1.0.0 -} showCheckFailure :: CheckFailure -> Text.Text showCheckFailure failure = let categoryText = showCategory (failureCategory failure) showInt :: Int -> Text.Text showInt = TL.toStrict . Builder.toLazyText . Builder.decimal in case failureDetail failure of ThresholdCheck.MinimumCoveredNotMet ( ThresholdCheck.MinimumNotMet { ThresholdCheck.mnmActualCovered = actual , ThresholdCheck.mnmRequiredCovered = required } ) -> Text.unlines [ [Interpolate.i| [Hcovguard-7342] Minimum covered not met|] , [Interpolate.i| Category: |] <> categoryText , [Interpolate.i| Actual: |] <> showInt actual <> [Interpolate.i| covered|] , [Interpolate.i| Required: |] <> showInt required <> [Interpolate.i| covered|] ] ThresholdCheck.MaximumUncoveredExceeded ( ThresholdCheck.MaximumExceeded { ThresholdCheck.meActualUncovered = actual , ThresholdCheck.meAllowedUncovered = allowed } ) -> Text.unlines [ [Interpolate.i| [Hcovguard-3928] Maximum uncovered exceeded|] , [Interpolate.i| Category: |] <> categoryText , [Interpolate.i| Actual: |] <> showInt actual <> [Interpolate.i| uncovered|] , [Interpolate.i| Allowed: |] <> showInt allowed <> [Interpolate.i| uncovered|] ] {- | Format failures grouped by module name. Prints a module header followed by all failures for that module. @since 0.1.0.0 -} showGroupedFailures :: Text.Text -> NonEmpty.NonEmpty CheckFailure -> Text.Text showGroupedFailures modName failures = Text.unlines [ [Interpolate.i|Module: |] <> modName , foldMap showCheckFailure failures ]