GUI’s are hard to create. Luckily for us, we can often get away with making our code available through a command line interface. As you start writing more Haskell programs, you’ll probably have to do this at some point. This article will go over some of the ins and outs of CLI’s. In particular, we’ll look at the basics of handling options. Then we’ll see some nifty techniques for actually testing the behavior of our CLI. A Simple Sample To motivate the examples in this article, let’s design a simple program We’ll have the user input a . Then we’ll print the message to a file a certain number of times and list the user’s name as the author at the top. We’ll also allow them to uppercase the message if they want. So we’ll get five pieces of input from the user: message The filename they want Their name to place at the top Whether they want to uppercase or not The message The repetition number We’ll use for the first three pieces of information. Then we’ll have a for the other two. For instance, we’ll insist the user pass the expected file name as an argument. Then we’ll take an option for the name the user wants to put at the top. Finally, we’ll take a flag for whether the user wants the message upper-cased. So here are a few different invocations of the program. arguments and options command line prompt >> run-cli “myfile.txt” -n “John Doe”What message do you want in the file?Sample MessageHow many times should it be repeated?5 This will print the following output to : myfile.txt From: John DoeSample MessageSample MessageSample MessageSample MessageSample Message Here’s another run, this time with an error in the input: >> run-cli “myfile2.txt” -n “Jane Doe” -uWhat message do you want in the file?A new messageHow many times should it be repeated?asdfSorry, that isn't a valid number. Please enter a number.3 This file will look like: From: Jane DoeA NEW MESSAGEA NEW MESSAGEA NEW MESSAGE Finally, if we don’t get the right arguments, we should get a usage error: >> run-cliMissing: FILENAME -n USERNAME Usage: CLIPractice-exe FILENAME -n USERNAME [-u] Comand Line Sample Program Getting the Input So the most important aspect of the program is . We’ll ignore the options for now. We’ll print a couple messages, and then use the function to get their input. There’s no way for them to give us a bad message, so this section is easy. getting the message and repetitions getLine getMessage :: IO StringgetMessage = do putStrLn "What message do you want in the file?" getLine But they might try to give us a number we can’t actually parse. So for this task, we’ll have to set up a loop where we keep asking the user for a number until they give us a good value. This will be recursive in the failure case. If the user won’t enter a valid number, they’ll have no choice but to terminate the program by other means. getRepetitions :: IO IntgetRepetitions = do putStrLn "How many times should it be repeated?" getNumber getNumber :: IO IntgetNumber = do rep <- getLine case readMaybe rep of Nothing -> do putStrLn "Sorry, that isn't a valid number. Please enter a number." getNumber Just i -> return i Once we’re doing reading the input, we’ll print the output to a file. In this instance, we hard-code all the options for now. Here’s the full program. import Data.Char (toUpper)import System.IO (writeFile)import Text.Read (readMaybe) runCLI :: IO ()runCLI = do let fileName = "myfile.txt" let userName = "John Doe" let isUppercase = False message <- getMessage reps <- getRepetitions writeFile fileName (fileContents userName message reps isUppercase) fileContents :: String -> String -> Int -> Bool -> StringfileContents userName message repetitions isUppercase = unlines $ ("From: " ++ userName) : (replicate repetitions finalMessage) where finalMessage = if isUppercase then map toUpper message else message Parsing Options Now we have to deal with the question of how we actually . We can do this by hand with the function, but this is somewhat error prone. A better option in general is to use the library. We’ll explore the different possibilities this library allows. We’ll use three different helper functions for the three pieces of input we need. parse the different options getArgs Options.Applicative The first thing we’ll do is build a data structure to hold the different options we want. We want to know the file name to store at, the name at the top, and the uppercase status. data CommandOptions = CommandOptions { fileName :: FilePath , userName :: String , isUppercase :: Bool } Now we need to parse each of these. We’ll start with the uppercase value. The most simple parser we have is the function. It tells us if a particular flag (we’ll call it ) is present, we’ll uppercase the message, otherwise not. It gets coded like this with the Options library: flag -u uppercaseParser :: Parser BooluppercaseParser = flag False True (short 'u') Notice we use in the final argument to denote the flag character. We could also use the function, since this flag is only a boolean, but this version is more general. short switch Now we’ll move on to the for the filename. This uses the helper function. We’ll use a string parser ( ) to ensure we get the actual string. We won’t worry about the filename having a particular format here. Notice we add some to this argument. This tells the user what they are missing if they don’t use the proper format. argument argument str metadata fileNameParser :: Parser StringfileNameParser = argument str (metavar "FILENAME") Finally, we’ll deal with the option of what name will go at the top. We could also do this as an argument, but let’s see what the option is like. An argument is a . An option on the other hand comes . We also add metadata here for a better error message as well. The piece of our metadata ensures it will use the option character we want. required positional parameter after a particular flag short userNameParser :: Parser FilePathuserNameParser = option str (short 'n' <> metavar "USERNAME") Now we have to combine these different parsers and add a little more info about our program. import Options.Applicative (execParser, info, helper, Parser, fullDesc, progDesc, short, metavar, flag, argument, str, option) parseOptions :: IO CommandOptionsparseOptions = execParser $ info (helper <*> commandOptsParser) commandOptsInfo where commandOptsParser = CommandOptions <$> fileNameParser <*> userNameParser <*> uppercaseParser commandOptsInfo = fullDesc <> progDesc "Command Line Sample Program" -- Revamped to take optionsrunCLI :: CommandOptions -> IO ()runCLI commandOptions = do let file = fileName commandOptions let user = userName commandOptions let uppercase = isUppercase commandOptions message <- getMessage reps <- getRepetitions writeFile file (fileContents user message reps uppercase) And now we’re done! We build our command object using these three different parsers. We chain the operations together using applicatives! Then we pass the result to our main program. If you aren’t too familiar with , and , we went over these a while ago on the blog. your ! functors applicatives Refresh memory IO Testing Now we have our program working, we need to ask ourselves how we . We can do manual command line tests ourselves, but it would be nice to have an automated solution. The key to this is the abstraction. test its behavior Handle Let’s first look at some basic file handling types. openFile :: FilePath -> IO HandlehGetLine :: Handle -> IO StringhPutStrLn :: Handle -> IO ()hClose :: Handle -> IO () Normally when we write something to a file, we for it. We use the handle (instead of the string literal name) for all the different operations. When we’re done, we close the handle. open a handle The good news is that the and streams are actually the exact same type under the hood! stdin stdout Handle stdin :: Handlestdout :: Handle How does this help us test? The first step is to abstract away the handles we’re working with. Instead of using and , we’ll want to use and . Then we take these parameters as arguments to our program and functions. Let’s look at our reading functions: print getLine hGetLine hPutStrLn getMessage :: Handle -> Handle -> IO StringgetMessage inHandle outHandle = do hPutStrLn outHandle "What message do you want in the file?" hGetLine inHandle getRepetitions :: Handle -> Handle -> IO IntgetRepetitions inHandle outHandle = do hPutStrLn outHandle "How many times should it be repeated?" getNumber inHandle outHandle getNumber :: Handle -> Handle -> IO IntgetNumber inHandle outHandle = do rep <- hGetLine inHandle case readMaybe rep of Nothing -> do hPutStrLn outHandle "Sorry, that isn't a valid number. Please enter a number." getNumber inHandle outHandle Just i -> return i Once we’ve done this, we can make the input and output handles parameters to our program as follows. Our wrapper executable will pass and : stdin stdout -- Library File:runCLI :: Handle -> Handle -> CommandOptions -> IO ()runCLI inHandle outHandle commandOptions = do let file = fileName commandOptions let user = userName commandOptions let uppercase = isUppercase commandOptions message <- getMessage inHandle outHandle reps <- getRepetitions inHandle outHandle writeFile file (fileContents user message reps uppercase) -- Executable Filemain :: IO ()main = do options <- parseOptions runCLI stdin stdout options Now our library API takes the handles as parameters. This means in our testing code, we can pass to test the code. And, as you may have guessed, we’ll do this with files, instead of and . We’ll make one file with our expected terminal output: whatever handle we want stdin stdout What message do you want in the file?How many times should it be repeated? We’ll make another file with our input: Sample Message5 And then the file we expect to be created: From: John DoeSample MessageSample MessageSample MessageSample MessageSample Message Now we can write a test calling our library function. It will pass the expected arguments object as well as the proper file handles. Then we can compare the output of our test file and the output file. import Lib import System.IOimport Test.HUnit main :: IO ()main = do inputHandle <- openFile "input.txt" ReadMode outputHandle <- openFile "terminal_output.txt" WriteMode runCLI inputHandle outputHandle options hClose inputHandle hClose outputHandle expectedTerminal <- readFile "expected_terminal.txt" actualTerminal <- readFile "terminal_output.txt" expectedFile <- readFile "expected_output.txt" actualFile <- readFile "testOutput.txt" assertEqual "Terminal Output Should Match" expectedTerminal actualTerminal assertEqual "Output File Should Match" expectedFile actualFile options :: CommandOptionsoptions = CommandOptions "testOutput.txt" "John Doe" False And that’s it! We can also use this process to add tests around the error cases, like when the user enters invalid numbers. Summary Writing a command line interface isn’t always the easiest task. Getting a user’s input sometimes requires creating loops if they won’t give you the information you want. Then dealing with arguments can be a major pain. The library contains many option parsing tools. It helps you deal with flags, options, and arguments. When you're ready to test your program, you'll want to abstract the file handles away. You can use and from your main executable. But then when you test, you can use files as your input and output handles. Options.Applicative stdin stdout Want to try writing a CLI but don’t know Haskell yet? No sweat! Download our and get going learning the language! Getting Started Checklist When you’re making a full project with executables and test suites, you need to keep organized! Take our FREE to learn how to organize your Haskell with Stack. Stack mini-course