Capstone: a todo CLI

Contents
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

Related articles
Haskell
Haskell course: zero to hero in 50 lessons
What is Haskell and why it matters
Install the toolchain with GHCup
GHCi: the interactive shell
Your first program
Values with let and where
Types and type signatures
Functions and application
Currying and partial application
Operators, sections, and fixity
if, guards, and pattern guards
case expressions
Pattern matching
Tuples
Lists
Ranges and enumeration
List comprehensions
Recursion replaces loops
map, filter, and foldr
Lambdas and composition
Type classes
Parametric polymorphism
Maybe and null safety
Either and recoverable errors
data: product types
data: sum types
Records
newtype and type synonyms
Recursive data: binary trees
Functor
Applicative
Monad
do notation
IO and purity
Files and IO
Modules and imports
Cabal projects
Dependencies
Testing with HUnit
Property testing with QuickCheck
Laziness and infinite lists
Strictness and space leaks
Folds
Monoids
Traversable
Monad transformers
Errors and exceptions
Concurrency
Parsing with Megaparsec
Capstone: a todo CLI
Hero roadmap: the whole course on one page

Search this site

Channel @aofeed Chat @aofeedchat

Contacts and cooperation:
I recommend our hosting beget.com
Write to info@aredel.com if you:
1. Want to write an article for our site or translate an article into your native language.
2. Want to place thematically relevant ads on the site.
3. Ads on my site pass maximum censorship. If you see an ad block unsuitable for school-age children, shocking or misleading - please contact us by e-mail
4. Found a mistake, inaccuracy, bug, etc. on the site. ... .......
5. Articles can be shared on social media by clicking a network icon: