{-# LANGUAGE QuasiQuotes #-} {- | Module : Options Description : Command line option parsing for hcovguard Copyright : (c) Trevis Elser, 2026 License : MIT Maintainer : oss@treviselser.com -} module Options ( Options (..) , OptionsError (..) , RunMode (..) , parseOptions , showOptionsError ) where import qualified Control.Exception as Exception import qualified Data.String.Interpolate as Interpolate import qualified Data.Text as Text import qualified OptEnvConf import qualified HCovGuard.Coverage as Coverage import qualified Paths_hcovguard as Paths import qualified System.OsPath as OsPath -- | The mode of operation for hcovguard data RunMode = -- | Normal operation: check coverage against thresholds RunCheck | -- | Generate a baseline config from current coverage RunBaseline | -- | Show which patterns match which modules without validation RunDryRun data Options = Options { configPath :: !OsPath.OsPath , tixPath :: !OsPath.OsPath , mixDirs :: ![OsPath.OsPath] , verbosity :: !Int , runMode :: !RunMode } -- | Errors that can occur when parsing options data OptionsError = PathEncodingError !FilePath !Text.Text | NoMixDirsSpecified | ConflictingModes {- | Format an options error as a human-readable message @since 0.1.0.0 -} showOptionsError :: OptionsError -> Text.Text showOptionsError (PathEncodingError path msg) = Text.unlines [ [Interpolate.i|Error: [Hcovguard-3741]|] , [Interpolate.i|Invalid path encoding|] , [Interpolate.i| Path: |] <> Text.pack path , [Interpolate.i| Error: |] <> msg ] showOptionsError NoMixDirsSpecified = Text.unlines [ [Interpolate.i|Error: [Hcovguard-2156]|] , [Interpolate.i|No .mix directories specified|] , [Interpolate.i|Either use --auto-discover to search common build directories,|] , [Interpolate.i|or specify at least one --mix-dir path.|] ] showOptionsError ConflictingModes = Text.unlines [ [Interpolate.i|Error: [Hcovguard-1847]|] , [Interpolate.i|Conflicting modes specified|] , [Interpolate.i|Use only one of: --baseline (-b) or --dry-run (-n)|] ] -- | Internal record for raw CLI parsing (uses FilePath) data RawOptions = RawOptions { rawConfigPath :: !FilePath , rawTixPath :: !FilePath , rawMixDirs :: ![FilePath] , rawAutoDiscover :: !Bool , rawVerbosity :: !Int , rawBaseline :: !Bool , rawDryRun :: !Bool } instance OptEnvConf.HasParser RawOptions where settingsParser = RawOptions <$> OptEnvConf.setting [ OptEnvConf.help "Path to the TOML configuration file" , OptEnvConf.reader OptEnvConf.str , OptEnvConf.option , OptEnvConf.long "config" , OptEnvConf.short 'c' , OptEnvConf.metavar "FILE" , OptEnvConf.value "./hcovguard.toml" ] <*> OptEnvConf.setting [ OptEnvConf.help "Path to the .tix file generated by HPC" , OptEnvConf.reader OptEnvConf.str , OptEnvConf.option , OptEnvConf.long "tix" , OptEnvConf.short 't' , OptEnvConf.metavar "FILE" ] <*> OptEnvConf.many ( OptEnvConf.setting [ OptEnvConf.help "Directory containing .mix files (can be specified multiple times)" , OptEnvConf.reader OptEnvConf.str , OptEnvConf.option , OptEnvConf.long "mix-dir" , OptEnvConf.short 'm' , OptEnvConf.metavar "DIR" ] ) <*> OptEnvConf.setting [ OptEnvConf.help "Auto-discover .mix directories in .stack-work and dist-newstyle" , OptEnvConf.switch True , OptEnvConf.long "auto-discover" , OptEnvConf.short 'a' , OptEnvConf.value False ] <*> OptEnvConf.setting [ OptEnvConf.help "Verbosity level (0=quiet, 1=normal, 2=verbose)" , OptEnvConf.reader OptEnvConf.auto , OptEnvConf.option , OptEnvConf.long "verbosity" , OptEnvConf.short 'v' , OptEnvConf.metavar "LEVEL" , OptEnvConf.value 1 ] <*> OptEnvConf.setting [ OptEnvConf.help "Generate baseline config from current coverage (outputs TOML to stdout)" , OptEnvConf.switch True , OptEnvConf.long "baseline" , OptEnvConf.short 'b' , OptEnvConf.value False ] <*> OptEnvConf.setting [ OptEnvConf.help "Show which modules match which patterns without running checks" , OptEnvConf.switch True , OptEnvConf.long "dry-run" , OptEnvConf.short 'n' , OptEnvConf.value False ] parseOptions :: IO (Either OptionsError Options) parseOptions = do rawOpts <- OptEnvConf.runSettingsParser Paths.version "hcovguard - Check HPC coverage data against configured thresholds" convertOptions rawOpts convertOptions :: RawOptions -> IO (Either OptionsError Options) convertOptions raw = do let encodeOne :: FilePath -> IO (Either OptionsError OsPath.OsPath) encodeOne filePath = let tryEncode :: IO (Either Exception.SomeException OsPath.OsPath) tryEncode = Exception.try (OsPath.encodeFS filePath) handleException :: Exception.SomeException -> Either OptionsError b handleException = Left . PathEncodingError filePath . Text.pack . Exception.displayException in fmap (either handleException Right) tryEncode configResult <- encodeOne (rawConfigPath raw) tixResult <- encodeOne (rawTixPath raw) mixResults <- if rawAutoDiscover raw && null (rawMixDirs raw) then do currentDir <- OsPath.encodeFS "." fmap (fmap Right) (Coverage.discoverMixDirs currentDir) else traverse encodeOne (rawMixDirs raw) let mixDirsResult = case sequenceA mixResults of Left err -> Left err Right [] | not (rawAutoDiscover raw) -> Left NoMixDirsSpecified Right dirs -> Right dirs modeResult = case (rawBaseline raw, rawDryRun raw) of (True, True) -> Left ConflictingModes (True, False) -> Right RunBaseline (False, True) -> Right RunDryRun (False, False) -> Right RunCheck pure $ Options <$> configResult <*> tixResult <*> mixDirsResult <*> pure (rawVerbosity raw) <*> modeResult