Capstone: a todo CLI
| The model | |
| A pure core | |
| Reading arguments | |
| The IO shell | |
| Testing it |
The model
The program keeps a list of tasks and supports four commands. The data type is a sum with two constructors, and the command type mirrors the command line one to one:
module Todo where
data Task = Task
{ taskId :: Int
, taskText :: String
, taskDone :: Bool
} deriving (Show, Eq)
data Command
= Add String
| List
| Done Int
| Remove Int
deriving (Show, Eq)
A Command is the parsed form of one line of input, and Task is one stored item. Both are plain data, so every function below is easy to test with a literal.
A pure core
The four operations are folds and maps over the list. Nothing here touches a file, and every function is total:
apply :: Command -> [Task] -> [Task]
apply (Add text) tasks =
tasks ++ [Task (nextId tasks) text False]
apply List tasks = tasks
apply (Done i) tasks =
[ t { taskDone = True } | t <- tasks, taskId t == i ]
++ [ t | t <- tasks, taskId t /= i ]
apply (Remove i) tasks =
filter ((/= i) . taskId) tasks
nextId :: [Task] -> Int
nextId [] = 1
nextId tasks = 1 + maximum (map taskId tasks)
The Done branch moves the matching task to the front so that a listing shows recent changes first. That is a product decision written in two list comprehensions, and it is testable in one line.
Reading arguments
getArgs returns the words after the program name. Turning them into a Command is pure string work, so it lives in the library next to the model:
import Text.Read (readMaybe)
parseCommand :: [String] -> Either String Command
parseCommand ["add"] = Left "add needs text"
parseCommand ("add" : ws) = Right (Add (unwords ws))
parseCommand ["list"] = Right List
parseCommand ["done", n] = Done <$> toInt n
parseCommand ["rm", n] = Remove <$> toInt n
parseCommand _ = Left "usage: todo (add TEXT | list | done N | rm N)"
toInt :: String -> Either String Int
toInt s = maybe (Left ("not a number: " ++ s)) Right (readMaybe s)
The catch-all equation makes the parser total. Every malformed line ends in a Left with a usage message instead of a pattern match failure.
The IO shell
The executable reads the file, applies the command, writes the file back, and prints a summary. All the logic is already written, so main is a sequence of glue:
import System.Environment (getArgs)
import System.Directory (doesFileExist)
main :: IO ()
main = do
args <- getArgs
case parseCommand args of
Left err -> putStrLn err
Right cmd -> do
tasks <- loadTasks "todo.txt"
let updated = apply cmd tasks
saveTasks "todo.txt" updated
mapM_ print updated
loadTasks and saveTasks convert between the list and a line based file. The format is one task per line, so a hand edit in any text editor works:
loadTasks :: FilePath -> IO [Task]
loadTasks path = do
exists <- doesFileExist path
if not exists then pure [] else do
text <- readFile path
pure (map parseLine (lines text))
saveTasks path tasks =
writeFile path (unlines (map show tasks))
show on a record writes a form that read can parse back, which is why deriving Show in the first section was enough. For a format you control, write the encoder by hand.
Testing it
Every interesting behaviour is in the pure core, so the test suite from lesson 38 covers the program without a single temporary file:
tests = TestList
[ "add assigns id 1" ~:?
TestCase (map taskId (apply (Add "milk") []) ~?= [1])
, "done marks one task" ~:?
TestCase (all taskDone (take 1 (apply (Done 1) twoTasks)) ~?= True)
, "rm drops one task" ~:?
TestCase (length (apply (Remove 1) twoTasks) ~?= 1)
, "bad command" ~:?
TestCase (isLeft (parseCommand ["nope"]) ~?= True)
]
Run cabal test and then the program itself:
cabal run todo -- add "write the capstone"
Task {taskId = 1, taskText = "write the capstone", taskDone = False}
That is a complete Haskell program: a data model, a pure core, a
total parser, a thin shell, and tests. The last lesson puts the whole
course on one page and points at what to learn next.
Next:
Hero roadmap: the whole course on one page
Article author: Arthur Isaev