{-# LANGUAGE QuasiQuotes #-} {- | Module : HCovGuard.Config.ForSpecifiedModule Description : Configuration for specific modules by name or pattern Copyright : (c) Trevis Elser, 2026 License : MIT Maintainer : oss@treviselser.com -} module HCovGuard.Config.ForSpecifiedModule ( ForSpecifiedModule (..) , ForSpecifiedModules , ModuleSelector (..) , RawForSpecifiedModule , forSpecifiedModulesCodec , moduleMatchesPatternWithIndex , showModuleSelector , ConfigValidationError (..) , validateForSpecifiedModules , showConfigValidationError ) where import qualified Data.List as List import qualified Data.String.Interpolate as Interpolate import qualified Data.Text as Text import qualified Toml import qualified HCovGuard.Config.Threshold as Threshold import qualified TomlHelper {- | Selector for matching modules - either exact name or glob pattern @since 0.1.0.0 -} data ModuleSelector = -- | Match a module by exact name ExactModule !Text.Text | -- | Match modules by glob pattern PatternModule !Text.Text {- | Coverage thresholds for a specific module @since 0.1.0.0 -} data ForSpecifiedModule = ForSpecifiedModule { specifiedModuleSelector :: !ModuleSelector , specifiedExpressionThreshold :: !(Maybe Threshold.CoverageThreshold) , specifiedTopLevelThreshold :: !(Maybe Threshold.CoverageThreshold) , specifiedAlternativeThreshold :: !(Maybe Threshold.CoverageThreshold) , specifiedLocalThreshold :: !(Maybe Threshold.CoverageThreshold) , specifiedIgnore :: !Bool } type ForSpecifiedModules = [ForSpecifiedModule] -- | Internal type for parsing TOML before validation data RawForSpecifiedModule = RawForSpecifiedModule { rawModule :: !(Maybe Text.Text) , rawPattern :: !(Maybe Text.Text) , rawExpressionThreshold :: !(Maybe Threshold.CoverageThreshold) , rawTopLevelThreshold :: !(Maybe Threshold.CoverageThreshold) , rawAlternativeThreshold :: !(Maybe Threshold.CoverageThreshold) , rawLocalThreshold :: !(Maybe Threshold.CoverageThreshold) , rawIgnore :: !Bool } forSpecifiedModulesCodec :: Toml.Key -> Toml.TomlCodec [RawForSpecifiedModule] forSpecifiedModulesCodec = Toml.list rawForSpecifiedModuleCodec rawForSpecifiedModuleCodec :: Toml.TomlCodec RawForSpecifiedModule rawForSpecifiedModuleCodec = RawForSpecifiedModule <$> TomlHelper.addField "module" rawModule (Toml.dioptional . Toml.text) <*> TomlHelper.addField "pattern" rawPattern (Toml.dioptional . Toml.text) <*> TomlHelper.addField "expression" rawExpressionThreshold optionalThreshold <*> TomlHelper.addField "topLevel" rawTopLevelThreshold optionalThreshold <*> TomlHelper.addField "alternative" rawAlternativeThreshold optionalThreshold <*> TomlHelper.addField "local" rawLocalThreshold optionalThreshold <*> TomlHelper.addField "ignore" rawIgnore (TomlHelper.setDefault False Toml.bool) optionalThreshold :: Toml.Key -> Toml.TomlCodec (Maybe Threshold.CoverageThreshold) optionalThreshold = Toml.dioptional . Toml.table thresholdTableCodec thresholdTableCodec :: Toml.TomlCodec Threshold.CoverageThreshold thresholdTableCodec = Threshold.CoverageThreshold <$> TomlHelper.addField "minimumCovered" Threshold.minimumCovered (Toml.dioptional . Toml.int) <*> TomlHelper.addField "maximumUncovered" Threshold.maximumUncovered (Toml.dioptional . Toml.int) {- | Check if a module name matches any pattern, returning the 0-based index @since 0.1.0.0 -} moduleMatchesPatternWithIndex :: Text.Text -> ForSpecifiedModules -> Maybe (Int, ForSpecifiedModule) moduleMatchesPatternWithIndex modName = List.find (matchesSpec modName . snd) . zip [0 ..] {- | Show the module selector for display purposes @since 0.1.0.0 -} showModuleSelector :: ModuleSelector -> Text.Text showModuleSelector (ExactModule m) = [Interpolate.i|module = "|] <> m <> [Interpolate.i|"|] showModuleSelector (PatternModule p) = [Interpolate.i|pattern = "|] <> p <> [Interpolate.i|"|] {-# INLINE matchesSpec #-} matchesSpec :: Text.Text -> ForSpecifiedModule -> Bool matchesSpec modName spec = case specifiedModuleSelector spec of ExactModule m -> m == modName PatternModule pat -> matchGlobPattern pat modName {- | Simple glob-style pattern matching supporting * and ** * matches any characters except . ** matches any characters including . Uses 'Text.uncons' for O(1) head/tail access instead of separate 'Text.head' and 'Text.tail' calls. @since 0.1.0.0 -} {-# INLINE matchGlobPattern #-} matchGlobPattern :: Text.Text -> Text.Text -> Bool matchGlobPattern pat str = case Text.uncons pat of Nothing -> Text.null str Just ('*', patRest) -> case Text.uncons patRest of Just ('*', patRestInner) -> matchStarStar patRestInner str _ -> matchStar patRest str Just (patChar, patRest) -> case Text.uncons str of Nothing -> False Just (strChar, strRest) -> patChar == strChar && matchGlobPattern patRest strRest {-# INLINE matchStar #-} matchStar :: Text.Text -> Text.Text -> Bool matchStar pat str = matchGlobPattern pat str || case Text.uncons str of Nothing -> False Just (c, rest) -> c /= '.' && matchStar pat rest {-# INLINE matchStarStar #-} matchStarStar :: Text.Text -> Text.Text -> Bool matchStarStar pat str = matchGlobPattern pat str || case Text.uncons str of Nothing -> False Just (_, rest) -> matchStarStar pat rest -- | Validation errors for configuration data ConfigValidationError = -- | Both module and pattern were specified ModuleAndPatternBothSpecified !Text.Text !Text.Text | -- | Neither module nor pattern was specified NeitherModuleNorPatternSpecified {- | Format a validation error as a human-readable message @since 0.1.0.0 -} showConfigValidationError :: ConfigValidationError -> Text.Text showConfigValidationError (ModuleAndPatternBothSpecified moduleName patternText) = Text.unlines [ [Interpolate.i|Error: [Hcovguard-4127]|] , [Interpolate.i|Configuration error: both 'module' and 'pattern' specified|] , [Interpolate.i| module: |] <> moduleName , [Interpolate.i| pattern: |] <> patternText , [Interpolate.i|Use 'module' for exact matching or 'pattern' for glob matching, but not both.|] ] showConfigValidationError NeitherModuleNorPatternSpecified = Text.unlines [ [Interpolate.i|Error: [Hcovguard-5834]|] , [Interpolate.i|Configuration error: neither 'module' nor 'pattern' specified|] , [Interpolate.i|Each [[forSpecifiedModules]] entry must have either:|] , [Interpolate.i| - 'module' for exact module name matching|] , [Interpolate.i| - 'pattern' for glob pattern matching|] ] {- | Validate and convert raw parsed modules to final form @since 0.1.0.0 -} validateForSpecifiedModules :: [RawForSpecifiedModule] -> Either ConfigValidationError ForSpecifiedModules validateForSpecifiedModules = let validateOne :: RawForSpecifiedModule -> Either ConfigValidationError ForSpecifiedModule validateOne raw = case (rawModule raw, rawPattern raw) of (Just modName, Just pat) -> Left (ModuleAndPatternBothSpecified modName pat) (Nothing, Nothing) -> Left NeitherModuleNorPatternSpecified (Just modName, Nothing) -> Right (convertRaw (ExactModule modName) raw) (Nothing, Just pat) -> Right (convertRaw (PatternModule pat) raw) convertRaw :: ModuleSelector -> RawForSpecifiedModule -> ForSpecifiedModule convertRaw selector raw = ForSpecifiedModule { specifiedModuleSelector = selector , specifiedExpressionThreshold = rawExpressionThreshold raw , specifiedTopLevelThreshold = rawTopLevelThreshold raw , specifiedAlternativeThreshold = rawAlternativeThreshold raw , specifiedLocalThreshold = rawLocalThreshold raw , specifiedIgnore = rawIgnore raw } in traverse validateOne