Small TODO apps

Posted on 2021-03-06 in Blog

I recently decided to lean a bit of Haskell for fun and see what the language was like. In this context, I decided to create a small TODO app. Since I also wanted to compare my solution with solutions in other languages, I decided to write the TODO app three times:

  • In Haskell to practice the language as well as monads.
  • In Rust because I like the language.
  • In Clojure because I already knew it and I wanted to practice it. It's a functional language like Haskell, so I though it would be interesting to include it in the comparaison.

Each app will do the exact same thing:

  • Initialize the database on startup, this means create the SQLite file and the table todo if required. This way, we can easily use it later.
  • Allow the database file to be specified to use a specific file if needed.
  • Store new TODO items in the database. Each TODO will be made of an id, a title and a completion status (ie done or not done).
  • List all TODOs.
  • Insert test data in the database.
  • Mark a TODO as done.

I'll talk a bit about each app and then try to conclude my experience. I'll start with the Rust one, then the Clojure one and finally the Haskell one (that's the order in which I wrote them). You will find the full source code in each section.

Rust

Rust is the language that most ressemble the languages I use everyday: Python and JavaScript. The goal of the language is to write fast and memory safe code without the need for a garbage collector. It's done thanks to its compiler and its type system (which I won't explain here). The errors of the compiler are generally explicit and clear. While it borrows some concepts from functional programming like immutability by default or if expressions, we can still write procedural or object like code. We also have to make some variables mutable from time to time (which is not possible in functional programming languages). So it's a mix, but an efficient one if you ask me.

I also think the community is striving and I found without any difficulty many libraries to parse command line arguments or communicate with the database. I settled with clap for argument parsing and rusqlite for SQLite communication. They both seems like popular and robust choices for what I was aiming for while staying simple to use.

Now, let's talk about the code itself. The definition of the parser ressembles what I could do in Python or JS, as you can see with the code sample below. I also find it very readable and expressive.

let matches = App::new("Todo application")
    .author("Jujens <jujens@jujens.eu>")
    .about("Small app to manage todos in a local SQLite database.")
    .setting(AppSettings::SubcommandRequiredElseHelp)
    .arg(
        Arg::with_name("file")
        .long("file")
        .takes_value(true)
        .default_value(DEFAULT_DB_PATH)
        .help("Path to the database.")
    )
    .subcommand(
        App::new("insert-test-data")
        .about("Insert test TODOs in the database")
    );

Since I have 4 different actions with different arguments, I decided to divide the code in subcommands. A pattern I reused for all apps.

If creating the parser is very similar to what I could have done in Python, when I had to use it, I saw some particularities of Rust: Rust doesn't have exceptions and forces you to handle all errors. For instance, when I create a TODO in handle_create, I pass a title with the --title option. Since the argument may not be provided, I get an Option from the parser: it's a data structure used in Rust to handle optional values, like Maybe in Haskell. When you have this structure, you need to extract the value from it before you can use it which forces you to handle the case where you have a value and the case where you haven't one. So the compiler, forces you to handle potential missing data. There is also a similar structure named Result which holds a value or an error. It's useful when you need to provide a useful error message and not just a value or None.

This is how it looks like:

let title = match matches.value_of("title") {
    Some(title) => title,
    None => {
        println!("You must supply a title for the match");
        exit(1);
    },
};

This is very interesting and allowed me to provide custom and precise error messages at each step. But, this makes the code quite verbose. To help a bit, you can use things like unwrap (which can result in a crash if the structure doesn't hold a value) or unwrap_or to extract the value or get a default value if needed. You can also use the ? operator like that:

conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false])?;

The compiler will propagate the error, meaning the compiler will replace the code above by:

let value = match conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false]) {
    Ok(value) => value,
    Err(e) => return Error(e),
};

which is much more compact and achieve the same result if you don't want/need to handle the error at call site. That's a very nice syntactic sugar I didn't used much to handle errors in order to provide the user with relevant messages.

The thing to remember (from my perspective at least) is that you can write code that crash at runtime if you don't pay attention or don't know what you are doing. The language will just give you tools to avoid this, it's then up to you to use those tools correctly.

Since all my other commands are written in the same way, I won't detail more: I think I have given you the gist of the code. To dig deeper, here is my Cargo.toml file with the project dependencies:

 1 [package]
 2 name = "todos"
 3 version = "0.1.0"
 4 authors = ["Julien Enselme <jenselme@jujens.eu>"]
 5 edition = "2018"
 6 
 7 [dependencies]
 8 clap = "2.33.3"
 9 
10 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
11 
12 [dependencies.rusqlite]
13 version = "0.24.2"
14 features = ["bundled"]

and the full src/main.rs file:

  1 use std::path::Path;
  2 use std::process::{ exit };
  3 
  4 use clap::{Arg, App, AppSettings, ArgMatches};
  5 
  6 use rusqlite::{params, Connection, Result};
  7 use rusqlite::NO_PARAMS;
  8 
  9 #[derive(Debug)]
 10 struct Todo {
 11     id: i32,
 12     title: String,
 13     is_done: bool,
 14 }
 15 
 16 fn main() {
 17     const DEFAULT_DB_PATH: &str = "./todos.db";
 18     let matches = App::new("Todo application")
 19         .author("Jujens <jujens@jujens.eu>")
 20         .about("Small app to manage todos in a local SQLite database.")
 21         .setting(AppSettings::SubcommandRequiredElseHelp)
 22         .arg(
 23             Arg::with_name("file")
 24             .short("f")
 25             .long("file")
 26             .takes_value(true)
 27             .default_value(DEFAULT_DB_PATH)
 28             .help("Path to the database.")
 29         )
 30         .subcommand(
 31             App::new("insert-test-data")
 32             .about("Insert test TODOs in the database")
 33         )
 34         .subcommand(
 35             App::new("list")
 36             .about("List all TODOs from the database")
 37         )
 38         .subcommand(
 39             App::new("create")
 40             .about("Create a new TODOs")
 41             .setting(AppSettings::ArgRequiredElseHelp)
 42             .arg(
 43                 Arg::with_name("title")
 44                 .short("t")
 45                 .long("title")
 46                 .takes_value(true)
 47                 .required(true)
 48                 .multiple(false)
 49                 .help("The title of the TODO to use.")
 50             )
 51         )
 52         .subcommand(
 53             App::new("complete")
 54             .about("Mark a TODO as done")
 55             .setting(AppSettings::ArgRequiredElseHelp)
 56             .arg(
 57                 Arg::with_name("id")
 58                 .required(true)
 59                 .help("The ID of the TODO to complete.")
 60             )
 61         )
 62         .get_matches();
 63 
 64     let mut conn = match init(matches.value_of("file").unwrap_or(DEFAULT_DB_PATH)) {
 65         Ok(conn) => conn,
 66         Err(e) => {
 67             println!("An error occurred while initializing the database: \"{}\".", e);
 68             exit(1);
 69         },
 70     };
 71 
 72     match matches.subcommand() {
 73         ("insert-test-data", _) => handle_insert_test_data(&mut conn),
 74         ("list", _) => handle_list_all(conn),
 75         ("create", creation_matches) => handle_create(conn, creation_matches),
 76         ("complete", Some(complete_matches)) => handle_complete(conn, complete_matches),
 77         _ => {
 78             println!("No valid subcommand was used");
 79             exit(1);
 80         },
 81     }
 82 }
 83 
 84 fn init(db_path: &str) -> Result<Connection> {
 85     if Path::new(db_path).exists() {
 86         return Ok(create_connection(db_path)?);
 87     }
 88 
 89     let conn = create_connection(db_path)?;
 90     conn.execute("CREATE TABLE IF NOT EXISTS todo (id integer primary key, title text not null, is_done boolean not null)", NO_PARAMS)?;
 91 
 92     return Ok(conn);
 93 }
 94 
 95 fn create_connection(db_path: &str) -> Result<Connection> {
 96     return Connection::open(db_path);
 97 }
 98 
 99 fn handle_insert_test_data(conn: &mut Connection) {
100     match insert_test_data(conn) {
101         Ok(()) => {},
102         Err(e) => {
103             println!("An error occurred while inserting test data: \"{}\"", e);
104             exit(1);
105         }
106     }
107 }
108 
109 fn insert_test_data(conn: &mut Connection) -> Result<()> {
110     let tx = conn.transaction()?;
111 
112     tx.execute("DELETE FROM todo;", NO_PARAMS).unwrap();
113     tx.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params!["Start project", true])?;
114     tx.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params!["Complete project", false])?;
115 
116     return tx.commit();
117 }
118 
119 fn handle_list_all(conn: Connection) {
120     match list_all(conn) {
121         Ok(results) => display_all(results),
122         Err(e) => {
123             println!("An error occurred while listing all todos: \"{}\".", e);
124             exit(1);
125         }
126     }
127 }
128 
129 fn display_all(todos: Vec<Todo>) {
130     for todo in todos.iter() {
131         println!("- id: {}, title: \"{}\", done: {}", todo.id, todo.title, todo.is_done);
132     }
133 }
134 
135 fn list_all(conn: Connection) -> Result<Vec<Todo>> {
136     let mut stmt = conn.prepare("SELECT id, title, is_done FROM todo")?;
137     let todos_iter = stmt.query_map(NO_PARAMS, |row| {
138         Ok(Todo {
139             id: row.get(0)?,
140             title: row.get(1)?,
141             is_done: row.get(2)?,
142         })
143     })?;
144 
145     let mut todos = vec![];
146     for todo_result in todos_iter {
147         match todo_result {
148             Ok(todo) => todos.push(todo),
149             Err(e) => println!("Error while fetching some todos: \"{}\".", e),
150         }
151     }
152 
153     return Ok(todos);
154 }
155 
156 fn handle_create(conn: Connection, create_matches: Option<&ArgMatches>) {
157     let matches = match create_matches {
158         Some(matches) => matches,
159         None => {
160             println!("You must supply a title for the match.");
161             exit(1);
162         }
163     };
164     let title = match matches.value_of("title") {
165         Some(title) => title,
166         None => {
167             println!("You must supply a title for the match");
168             exit(1);
169         },
170     };
171 
172     match create(conn, title) {
173         Ok(()) => {},
174         Err(e) => {
175             println!("An error occurred while creating a new TODO: \"{}\"", e);
176             exit(1);
177         }
178     };
179 }
180 
181 fn create(conn: Connection, title: &str) -> Result<()> {
182     conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false])?;
183 
184     return Ok(());
185 }
186 
187 fn handle_complete(conn: Connection, complete_matches: &ArgMatches) {
188     let raw_id = match complete_matches.value_of("id") {
189         Some(raw_id) => raw_id,
190         None => {
191             println!("You must supply an id");
192             exit(1);
193         }
194     };
195 
196     let id = match raw_id.parse::<u32>() {
197         Ok(id) => id,
198         Err(e) => {
199             println!("You must supply a valid ID (an interger): \"{}\"", e);
200             exit(1);
201         }
202     };
203 
204     match complete(conn, id) {
205         Ok(()) => {},
206         Err(e) => {
207             println!("An error occurred while completing the TODO {}: \"{}\"", id, e);
208         }
209     }
210 }
211 
212 fn complete(conn: Connection, id: u32) -> Result<(), String> {
213     let nb_rows_updated = conn.execute("UPDATE todo SET is_done = ?1 WHERE id = ?2", params![true, id]);
214 
215     return match nb_rows_updated {
216         Ok(0) => Err(format!("No row found for supplied id: {}.", id)),
217         Err(e) => Err(format!("{}", e)),
218         _ => Ok(()),
219     };
220 }

Clojure

Clojure is very different. It's a LISP where all the code is written as lists (like in all LISPs) that runs on the JVM. All data structures are immutable and variables are dynamically typed. The language aims to focus on the data instead of the program.

It was easy to find libraries but I found that the parser was less complete than the Rust or Haskell one (or Python ones for that matter). I guess it comes from the fact that Clojure is a niche language that is less known and has a smaller community.

I had more manual work to do to parse my arguments, including the validation. I think that when you are not used to the language or another LISP, the program is hard to decode with everything having the same structure. Since it's also very different, it's also way harder to understand if you don't already know the language.

I defined the options of the parser in a vector (similar to a list in Python):

(def cli-options
    [["-f" "--file PORT" "Path to the database file to use."
    :default "./todos.db"]
    ["-h" "--help"]])

I can then use this with the parse-opts function to extract my arguments (and potential errors):

(let [{:keys [options arguments errors summary]} (parse-opts args cli-options :in-order true)]
    "The code")

The parse-opts function will return a map (a dict in Python), the :keys allows me to extract values from this map (options, argument…) and the let expression allows me to bind these values to local variables (with the same name they had in the map). I can then use these values to build a map either to exit the program later with a message or to execute actual code based on the supplied arguments. I used a similar pattern to define and parse the options of my subcommands.

I won't detail much here since I think it would take too much time to teach Clojure here. I just say that all the fonctions themselves are way shorter than in the Rust version since I could easily handle all exceptions globally with a catch handler (Clojure relies on exception for errors). The price is: I'm less precise in the messages I give. But I could avoid this by adding more catch handlers at the price of verbosity. I'll also point out that all functions that ends with an exclamation mark like insert! mutate the sate by convention and must not be confused by by the exclamation mark used to identify macros in Rust. With that, you should be able to get a high level understanding of what the program does.

Like above, here is my project.clj with project dependencies:

 1 (defproject todo "0.1.0-SNAPSHOT"
 2   :description "FIXME: write description"
 3   :url "http://example.com/FIXME"
 4   :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
 5             :url "https://www.eclipse.org/legal/epl-2.0/"}
 6   :dependencies [[org.clojure/clojure "1.10.1"]
 7                  [org.clojure/tools.cli "1.0.194"]
 8                  [org.clojure/java.jdbc "0.7.12"]
 9                  [org.xerial/sqlite-jdbc "3.34.0"]]
10   :main ^:skip-aot todo.core
11   :target-path "target/%s"
12   :profiles {:uberjar {:aot :all}})

and my src/todo/core.clj source file:

  1 (ns todo.core
  2   (:require [clojure.tools.cli :refer [parse-opts]]
  3             [clojure.string :as string]
  4             [clojure.java.jdbc :as jdbc])
  5   (:gen-class)
  6   (:import (clojure.lang ExceptionInfo)))
  7 
  8 (def allowed-commands #{"insert-test-data", "list", "create", "complete"})
  9 
 10 (def command->options {
 11                        "insert-test-data" [["-h" "--help"]]
 12                        "list" [["-h" "--help"]]
 13                        "create" [["-h" "--help"]
 14                                  ["-t" "--title TITLE" "The title of the todo"]]
 15                        "complete" [["-h" "--help"]
 16                                    [nil "--id ID" "The ID of the TODO to mark as done"
 17                                     :parse-fn #(Integer/parseInt %)]]
 18                        })
 19 
 20 (def command->desc {
 21                        "insert-test-data" "Insert test TODOs into the database"
 22                        "list" "List all TODOs from the database"
 23                        "create" "Create a new TODO"
 24                        "complete" "Mark the TODO as done"
 25                        })
 26 
 27 (def cli-options
 28   [["-f" "--file PORT" "Path to the database file to use."
 29     :default "./todos.db"]
 30    ["-h" "--help"]])
 31 
 32 (defn- usage [options-summary]
 33   (->> ["This is a sample TODO program to create and update TODOs"
 34         "Usage: todo [options] COMMAND [command-options]"
 35         ""
 36         "Options:"
 37         options-summary
 38         ""
 39         "Commands:"
 40         (string/join \newline allowed-commands)]
 41        (string/join \newline)))
 42 
 43 (defn- error-msg [errors]
 44   (str "The following errors occurred while parsing your command:\n"
 45        (string/join \newline errors)))
 46 
 47 (defn- exit [status msg]
 48   (println msg)
 49   (System/exit status))
 50 
 51 (defn- command-usage [command summary]
 52   (string/join \newline [(command->desc command) summary]))
 53 
 54 (defn- validate-command-args
 55   "Validate arguments for each subcommands"
 56   [args global-options]
 57   (let [command (first args)
 58         command-args (rest args)
 59         {:keys [options arguments errors summary]} (parse-opts args (command->options command))]
 60     (cond
 61       errors {:exit-message (error-msg errors) :ok? false}
 62       (:help options) {:exit-message (command-usage command summary) :ok? true}
 63       :else {:command command :options options :global-options global-options})))
 64 
 65 (defn- validate-args
 66   "Validate command line arguments. Either return a map indicating the program
 67   should exit (with a error message, and optional ok status), or a map
 68   indicating the action the program should take and the options provided."
 69   [args]
 70   (let [{:keys [options arguments errors summary]} (parse-opts args cli-options :in-order true)]
 71     (cond
 72       (:help options) {:exit-message (usage summary) :ok? true}
 73       errors {:exit-message (error-msg errors) :ok? false}
 74       (and (<= 1 (count arguments))
 75            (allowed-commands (first arguments))) (validate-command-args arguments options)
 76       :else {:exit-message (usage summary)})))
 77 
 78 (defn- insert-test-data
 79   [db]
 80   (jdbc/insert-multi! db :todo
 81                       [:title :done]
 82                       [["Start the project" true]
 83                        ["Complete the project" false]]))
 84 
 85 (defn- display-todo
 86   [todo]
 87   (->> [(str "- id: " (:id todo))
 88         (str "title: " (:title todo))
 89         (str "done: " (if (= (:done todo) 1) "true" "false"))]
 90        (string/join ", ")
 91        (println)))
 92 
 93 (defn- list-all
 94   [db]
 95   (let [rows (jdbc/query db ["SELECT id, title, done FROM todo"])]
 96     (doseq [todo rows]
 97       (display-todo todo))))
 98 
 99 (defn- create
100   [conn options]
101   (if-let [title (:title options)]
102     (jdbc/insert! conn :todo {:title title :done false})
103     (exit 1 (error-msg ["You must supply a title with --title"]))))
104 
105 (defn- complete
106   [conn options]
107   (if-let [id (:id options)]
108     (let [nb-rows-updated (first (jdbc/update! conn :todo {:done true} ["id = ?" id]))]
109       (if (not (= nb-rows-updated 1))
110         (exit 1 (str "No rows could be found for id " id))))
111     (exit 1 (error-msg ["You must supply the id of the TODO with --id"]))))
112 
113 (defn- init
114   [db]
115   (jdbc/db-do-commands db
116                        (jdbc/create-table-ddl :todo [[:id :integer :primary :key]
117                                                      [:title :text "not null"]
118                                                      [:done :boolean :default false "not null"]]
119                                               {:conditional? true})))
120 
121 (defn- run
122   [global-options command options]
123   (let [db {:classname   "org.sqlite.JDBC"
124             :subprotocol "sqlite"
125             :subname     (:file global-options)}]
126     (jdbc/with-db-transaction [conn db]
127                               (try
128                                 (init conn)
129                                 (case command
130                                   "insert-test-data" (insert-test-data conn)
131                                   "list" (list-all conn)
132                                   "create" (create conn options)
133                                   "complete" (complete conn options))
134                                 (catch Exception ex
135                                   (println
136                                     "An exception occurred while performing " command
137                                     " with options " options
138                                     " and global options " global-options
139                                     ": "
140                                     (.getMessage ex)))))))
141 
142 (defn -main
143   [& args]
144   (let [{:keys [command options global-options exit-message ok?]} (validate-args args)]
145     (if exit-message
146       (exit (if ok? 0 1) exit-message)
147       (run global-options command options))))

Haskell

Haskell is also very different from other languages I normally use and it's often cited as the reference of functional languages. In Haskell, functions are more like mathematical functions, all variables are immutable and all side effects are encapsulated to maintain function purity. It also relies on indentation to express the code and don't need semi-colon or curly braces or to wrap everything in parentheses. The type system is very expressive and is a bit hard to read and understand when you start (mostly when many Monads are involved in functions signatures). Like for Clojure, I'll analyse part of the code but won't attempt to teach you Haskell.

The parsing of the arguments relies heavily on the type system and on the combination of function. For instance, to parse the title used to create a TODO, first I create a union type to hold all the commands (deriving Show is an easy way to be able to print the data):

data Command =
    List
    | InsertTestData
    | Create CreateOptions
    | Complete CompleteOptions
    deriving Show

Then, I create a type named CreateOptions to hold the options:

data CreateOptions = CreateOptions { title :: String }

then a function to parse the option themselves:

createOptionsParser :: Parser CreateOptions
createOptionsParser = CreateOptions
    <$> strOption (long "title" <> short 't' <> help "The title of the TODO." <> metavar "TITLE")

That's readable but to really understand what is going on you need to understand what <$> and <> do which isn't simple and which I won't explain there.

I can then create the parser for the command itself:

createParser :: Parser Command
createParser = Create <$> createOptionsParser

which can then be used in the subcommand parser.

The arguments will be parsed in a do block to handle side effects (another important point I won't explain).

The most interesting part is the use of pattern matching to run the proper command based on the actual type of the parsed command:

runCommand :: Command -> Connection -> IO ()
runCommand List = listAll
runCommand InsertTestData = insertTestData
runCommand (Create CreateOptions { title = title }) = createTodo title
runCommand (Complete CompleteOptions { todoId = todoId }) = completeTodo todoId

The parser will create a Command type of "type" List, Create… and we will use this syntax to execute specific code depending on the actual type of the command and extract their parameters with destructuring. I find that very expressive and concise. And it avoids to have complex if or case. I also note that with its usage of parenthesis, Haskell code sometimes makes me thing of LISP.

Now that I managed to parse the options, I can do things, for instance create a TODO:

createTodo :: String -> Connection -> IO()
createTodo title conn = do
    insertResult <- try (execute conn "INSERT INTO todo (title, is_done) values (?, ?)" (title, False :: Bool)) :: IO (Either SomeException ())
    case insertResult of
        (Left error) -> putStrLn $ "Failed to insert the todo: " ++ show error
        (Right result) -> pure ()

The way I handle the errors highly ressemble how it's done in Rust. The try function is a way to catch the potential errors from the executed query. Without it, the code would crash if execute encountered an error. So yes, Haskell code can compile and crash at runtime if you don't pay attention (or don't know what you are doing) in some cases. I find that a bit weird given the preconception I had about the language which was all errors must be handled and if it compiles it works. To be fair, this can also happen in Rust, when you try to access an index that doesn't exist in a array or with invalid use of unwrap for instance. I guess no language is the silver bullet in that matter.

As usual, here's the code of my app/Main.hs file which serves as an entry point with parsing logic:

 1 {-# LANGUAGE BlockArguments #-}
 2 
 3 module Main where
 4 
 5 import Lib
 6 import Options.Applicative
 7 import Data.Semigroup ((<>))
 8 import Database.SQLite.Simple ( Connection )
 9 
10 data Options = Options GlobalOptions Command
11 
12 data GlobalOptions = GlobalOptions { file :: String } deriving Show
13 
14 data CreateOptions = CreateOptions { title :: String } deriving Show
15 
16 data CompleteOptions = CompleteOptions { todoId :: Int } deriving Show
17 
18 data Command =
19   List
20   | InsertTestData
21   | Create CreateOptions
22   | Complete CompleteOptions
23   deriving Show
24 
25 listParser :: Parser Command
26 listParser = pure List
27 
28 insertTestDataParser :: Parser Command
29 insertTestDataParser = pure InsertTestData
30 
31 createParser :: Parser Command
32 createParser = Create <$> createOptionsParser
33 
34 createOptionsParser :: Parser CreateOptions
35 createOptionsParser = CreateOptions
36   <$> strOption (long "title" <> short 't' <> help "The title of the TODO." <> metavar "TITLE")
37 
38 completeParser :: Parser Command
39 completeParser = Complete <$> completeOptionsParser
40 
41 completeOptionsParser :: Parser CompleteOptions
42 completeOptionsParser = CompleteOptions
43   <$> argument auto (metavar "ID" <> help "The ID of the todo to complete")
44 
45 globalOptionsParser :: Parser GlobalOptions
46 globalOptionsParser = GlobalOptions
47   <$> strOption (long "file" <> short 'f' <> help "Path to the database file." <> metavar "FILE" <> value "./todos.db")
48 
49 commandParser :: Parser Command
50 commandParser = hsubparser
51   (
52     command "list" (info listParser (progDesc "List all TODOs"))
53     <> command "insert-test-data" (info insertTestDataParser (progDesc "Add test TODOs in the database"))
54     <> command "create" (info createParser (progDesc "Create a new TODO."))
55     <> command "complete" (info completeParser (progDesc "Mark a TODO as done."))
56   )
57 
58 optionsParser :: Parser Options
59 optionsParser = Options
60   <$> globalOptionsParser
61   <*> commandParser
62 
63 main :: IO ()
64 main = run =<< execParser opts
65   where
66     opts = info (optionsParser <**> helper)
67       (fullDesc <> progDesc "Manage TODOs")
68 
69 run :: Options -> IO ()
70 run (Options globalOptions command) = do
71   conn <- initDb
72   runCommand command conn
73 
74 runCommand :: Command -> Connection -> IO ()
75 runCommand List = listAll
76 runCommand InsertTestData = insertTestData
77 runCommand (Create CreateOptions { title = title }) = createTodo title
78 runCommand (Complete CompleteOptions { todoId = todoId }) = completeTodo todoId

and the src/Lib.hs with the rest of the logic:

 1 {-# LANGUAGE DeriveGeneric #-}
 2 {-# LANGUAGE OverloadedStrings #-}
 3 
 4 module Lib
 5     ( listAll,
 6       initDb,
 7       insertTestData,
 8       createTodo,
 9       completeTodo,
10     ) where
11 
12 import GHC.Generics
13 import Control.Monad (forM, forM_)
14 import Prelude hiding (id)
15 import Control.Applicative
16 import Control.Exception
17 import Database.SQLite.Simple ( open, execute, executeNamed, execute_, query_, changes, withTransaction, Connection, NamedParam( (:=) ) )
18 import Database.SQLite.Simple.FromRow
19 
20 data Todo = Todo Int String Bool deriving (Generic, Show)
21 
22 -- The number of field here must match the number of fields we get from the SELECT.
23 instance FromRow Todo where
24   fromRow = Todo <$> field <*> field <*> field
25 
26 initDb :: IO Connection
27 initDb = do
28   conn <- open "./todos.db" -- The database file will be created if it doesn't exist.
29   execute_ conn "CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title STR NOT NULL, is_done BOOLEAN NOT NULL)"
30   return conn
31 
32 insertTestData :: Connection -> IO ()
33 insertTestData conn = do
34     withTransaction conn $ replaceByTestData conn
35 
36 replaceByTestData :: Connection -> IO ()
37 replaceByTestData conn = do
38   execute_ conn "DELETE FROM todo;"
39   execute conn "INSERT INTO todo (title, is_done) values (?, ?)" ("Start project" :: String, True :: Bool)
40   execute conn "INSERT INTO todo (title, is_done) values (?, ?)" ("Complete project" :: String, False :: Bool)
41 
42 listAll :: Connection -> IO ()
43 listAll conn = do
44   todos <- try (query_ conn "SELECT id, title, is_done FROM todo;" :: IO [Todo]) :: IO (Either SomeException [Todo])
45   case todos of
46     (Left error) -> putStrLn $ "Failed to list all todos: " ++ show error
47     (Right allTodos) -> mapM_ (putStrLn . formatTodo) allTodos
48 
49 formatTodo :: Todo -> String
50 formatTodo (Todo id title isDone) = "- id: " ++ show id ++ ", title: " ++ show title ++ ", done: " ++ show isDone
51 
52 createTodo :: String -> Connection -> IO()
53 createTodo title conn = do
54   insertResult <- try (execute conn "INSERT INTO todo (title, is_done) values (?, ?)" (title, False :: Bool)) :: IO (Either SomeException ())
55   case insertResult of
56       (Left error) -> putStrLn $ "Failed to insert the todo: " ++ show error
57       (Right result) -> pure ()
58 
59 
60 completeTodo :: Int -> Connection -> IO()
61 completeTodo todoId conn = do
62   completeResult <- try (executeNamed conn "UPDATE todo SET is_done = true WHERE id = :id" [":id" := todoId]) :: IO (Either SomeException ())
63   case completeResult of
64     (Left error) -> putStrLn $ "Failed to update the todo: " ++ show error
65     (Right _) -> do
66       nbUpdatedRows <- changes conn
67       displayUpdateMessage nbUpdatedRows
68 
69 displayUpdateMessage :: Int -> IO()
70 displayUpdateMessage 0 = putStrLn "Failed to find a todo with the supplied id."
71 displayUpdateMessage _ = pure ()

Here are also my stack.yaml file and may package.yaml. I didn't include them here since they are way longer that the other dependencies files and give extra details about how the project must be compiled.

Wrapping up

Some stats to compare each solution:

  • The full code of the Rust version is 220 lines, but only 164 lines of code when I remove the lines with the closing curly which we don't have in other languages. It's also the app where I have the most detailed and precise error messages. It felt right to write it this way.
  • The Clojure version is 147 lines long.
  • The Haskell version is 149 lines long.

So I'd say in terms of code length, they all are pretty much the same, with Rust being a bit more verbose.

I'd like to say I enjoyed writing all these apps. The Haskell one was the most challenging to write: it's the language I know the least and is the most different from what I am use to writing. Their usage of Monad and of IO in type signature was (and still is a bit) obscure to me. I think these signatures can be obvious to someone at ease with Haskell, but as a newcomer, without examples to illustrate how the function actually works, it was hard to understand. I often had to search the Internet for examples to help me write the code. I also struggled a bit with the operators <$> and <*>.

I'm also a bit disappointed by the Clojure version: the argument parser is less complete than the other ones and I was expecting more of it. I also still have issues reading the code at time, mostly when I want to scan the code to find something. Maybe it's just a habit to work on.

I think the Rust one shines: it's expressive, errors must always be handled correctly and I found many examples as well as solid library to implement what I was trying to do. This small experiment made the language more interesting for me. It's definitely the one I'm most exited about although I still don't think I'll use the language much aside from toy project for now: I find it a bit verbose and complex for the web app I am writing.

I'll try to write a small web API in the following weeks to complete my experience (and for fun) and write an complementary blog post.

In July, I finally wrote the follow up article: Small API to manage packages.