validationhaskellcommand-line-argumentsoptparse-applicative

Programming pattern or library (i.e. idiomatic way) to handle CLI arguments semantic errors?


I have a Haskell application which uses optparse-applicative library for CLI arguments parsing. My data type for CLI arguments contains FilePaths (both files and directories), Doubles and etc. optparse-applicative can handle parse errors but I want to ensure that some files and some directories exist (or don't exist), numbers are >= 0 and etc.

What can be done is an implementation of a bunch of helper functions like these ones:

exitIfM :: IO Bool -> Text -> IO ()
exitIfM predicateM errorMessage = whenM predicateM $ putTextLn errorMessage >> exitFailure 

exitIfNotM :: IO Bool -> Text -> IO ()
exitIfNotM predicateM errorMessage = unlessM predicateM $ putTextLn errorMessage >> exitFailure 

And then I use it like this:

body :: Options -> IO ()
body (Options path1 path2 path3 count) = do
    exitIfNotM (doesFileExist path1) ("File " <> (toText ledgerPath) <> " does not exist") 
    exitIfNotM (doesDirectoryExist path2) ("Directory " <> (toText skKeysPath) <> " does not exist")
    exitIfM (doesFileExist path3) ("File " <> (toText nodeExe) <> " already exist")
    exitIf (count <= 0) ("--counter should be positive")

This looks too ad-hoc and ugly to me. Also, I need similar functionality for almost every application I write. Are there some idiomatic ways to deal with this sort of programming pattern when I want to do a bunch of checks before actually doing something with data type? The less boilerplate involved the better it is :)


Solution

  • Instead of validating the options record after it has been constructed, perhaps we could use applicative functor composition to combine argument parsing and validation:

    import Control.Monad
    import Data.Functor.Compose
    import Control.Lens ((<&>)) -- flipped fmap
    import Control.Applicative.Lift (runErrors,failure) -- form transformers
    import qualified Options.Applicative as O
    import System.Directory -- from directory
    
    data Options = Options { path :: FilePath, count :: Int } deriving Show
    
    main :: IO ()
    main = do
        let pathOption = Compose (Compose (O.argument O.str (O.metavar "FILE") <&> \file ->
                do exists <- doesPathExist file
                   pure $ if exists
                          then pure file
                          else failure ["Could not find file."]))
            countOption = Compose (Compose (O.argument O.auto (O.metavar "INT") <&> \i ->
                do pure $ if i < 10
                          then pure i
                          else failure ["Incorrect number."]))
            Compose (Compose parsy) = Options <$> pathOption <*> countOption
        io <- O.execParser $ O.info parsy mempty
        errs <- io
        case runErrors errs of
            Left msgs -> print msgs
            Right r -> print r
    

    The composed parser has type Compose (Compose Parser IO) (Errors [String]) Options. The IO layer is for performing file existence checks, while Errors is a validation-like Applicative from transformers that accumulates error messages. Running the parser produces an IO action that, when run, produces an Errors [String] Options value.

    The code is a bit verbose but those argument parsers could be packed in a library and reused.

    Some examples form the repl:

    Λ :main "/tmp" 2
    Options {path = "/tmp", count = 2}
    Λ :main "/tmpx" 2
    ["Could not find file."]
    Λ :main "/tmpx" 22
    ["Could not find file.","Incorrect number."]