Building a Todo List CLI in Rust with SQLite

In this project, you will build a basic command-line to-do list app in Rust. It stores tasks in a SQLite database using rusqlite, and accepts commands from the user via clap. This teaches you real-world CRUD operations: create, read, update, delete.

When I first built a Rust CLI with persistent data, I wanted something minimal but functional. This project gave me that. With just one file and a few commands, I could manage tasks from the terminal, saved in a real database file I could inspect later.

What You Will Learn

  • Defining CLI commands with clap
  • Creating and connecting to a SQLite database using rusqlite
  • Inserting, listing, marking done, and deleting tasks
  • Writing clean command-based logic in Rust

Step 1: Add Dependencies

In Cargo.toml:

[dependencies]
clap = { version = "4.4", features = ["derive"] }
rusqlite = "0.30"

Step 2: Define Command-Line Arguments

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "todo")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Add { task: String },
    List,
    Done { id: i32 },
    Delete { id: i32 },
}

Step 3: Setup the SQLite Database

use rusqlite::{Connection, Result};

fn init_db() -> Result<Connection> {
    let conn = Connection::open("todo.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks (
            id    INTEGER PRIMARY KEY,
            task  TEXT NOT NULL,
            done  INTEGER NOT NULL DEFAULT 0
        )",
        [],
    )?;
    Ok(conn)
}

Step 4: Implement Command Handlers

fn add_task(conn: &Connection, task: &str) -> Result<()> {
    conn.execute("INSERT INTO tasks (task) VALUES (?1)", [task])?;
    println!("Task added: {}", task);
    Ok(())
}

fn list_tasks(conn: &Connection) -> Result<()> {
    let mut stmt = conn.prepare("SELECT id, task, done FROM tasks")?;
    let tasks = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i32>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, i32>(2)?,
        ))
    })?;

    for task in tasks {
        let (id, text, done) = task?;
        let status = if done == 1 { "[x]" } else { "[ ]" };
        println!("{} {}: {}", status, id, text);
    }

    Ok(())
}

fn mark_done(conn: &Connection, id: i32) -> Result<()> {
    let changed = conn.execute("UPDATE tasks SET done = 1 WHERE id = ?1", [id])?;
    if changed == 0 {
        println!("No task found with ID {}", id);
    } else {
        println!("Marked task {} as done", id);
    }
    Ok(())
}

fn delete_task(conn: &Connection, id: i32) -> Result<()> {
    let deleted = conn.execute("DELETE FROM tasks WHERE id = ?1", [id])?;
    if deleted == 0 {
        println!("No task found with ID {}", id);
    } else {
        println!("Deleted task {}", id);
    }
    Ok(())
}

Step 5: Main Function

fn main() -> Result<()> {
    let cli = Cli::parse();
    let conn = init_db()?;

    match cli.command {
        Commands::Add { task } => add_task(&conn, &task)?,
        Commands::List => list_tasks(&conn)?,
        Commands::Done { id } => mark_done(&conn, id)?,
        Commands::Delete { id } => delete_task(&conn, id)?,
    }

    Ok(())
}

Full Program

use clap::{Parser, Subcommand};
use rusqlite::{Connection, Result};

#[derive(Parser)]
#[command(name = "todo")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Add { task: String },
    List,
    Done { id: i32 },
    Delete { id: i32 },
}

fn init_db() -> Result<Connection> {
    let conn = Connection::open("todo.db")?;
    conn.execute(
        "CREATE TABLE IF NOT EXISTS tasks (
            id    INTEGER PRIMARY KEY,
            task  TEXT NOT NULL,
            done  INTEGER NOT NULL DEFAULT 0
        )",
        [],
    )?;
    Ok(conn)
}

fn add_task(conn: &Connection, task: &str) -> Result<()> {
    conn.execute("INSERT INTO tasks (task) VALUES (?1)", [task])?;
    println!("Task added: {}", task);
    Ok(())
}

fn list_tasks(conn: &Connection) -> Result<()> {
    let mut stmt = conn.prepare("SELECT id, task, done FROM tasks")?;
    let tasks = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i32>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, i32>(2)?,
        ))
    })?;

    for task in tasks {
        let (id, text, done) = task?;
        let status = if done == 1 { "[x]" } else { "[ ]" };
        println!("{} {}: {}", status, id, text);
    }

    Ok(())
}

fn mark_done(conn: &Connection, id: i32) -> Result<()> {
    let changed = conn.execute("UPDATE tasks SET done = 1 WHERE id = ?1", [id])?;
    if changed == 0 {
        println!("No task found with ID {}", id);
    } else {
        println!("Marked task {} as done", id);
    }
    Ok(())
}

fn delete_task(conn: &Connection, id: i32) -> Result<()> {
    let deleted = conn.execute("DELETE FROM tasks WHERE id = ?1", [id])?;
    if deleted == 0 {
        println!("No task found with ID {}", id);
    } else {
        println!("Deleted task {}", id);
    }
    Ok(())
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let conn = init_db()?;

    match cli.command {
        Commands::Add { task } => add_task(&conn, &task)?,
        Commands::List => list_tasks(&conn)?,
        Commands::Done { id } => mark_done(&conn, id)?,
        Commands::Delete { id } => delete_task(&conn, id)?,
    }

    Ok(())
}

Example Usage

cargo run -- add "Write Rust blog post"
cargo run -- list
cargo run -- done 1
cargo run -- delete 2

Handling rusqlite Linking Error on Windows

If you try to run the project as-is on Windows, you’ll likely see this error:

rusqlite Linking Error on Windows

Rust’s rusqlite crate depends on the native SQLite C library. On Windows, if you are not using the bundled feature, it expects a sqlite3.lib file to already exist on your system and be accessible to the linker. But by default:

  • sqlite3.lib is not included in the downloaded SQLite DLLs
  • You don’t have sqlite3.lib unless you build it yourself or use a C/C++ package manager like vcpkg
  • The Rust compiler can’t find or link to it, which causes the LNK1181 error

To make your project work without installing SQLite manually, just update your Cargo.toml to:

[dependencies]
clap = { version = "4.4", features = ["derive"] }
rusqlite = { version = "0.30", features = ["bundled"] }

This tells rusqlite to compile SQLite from source and include it inside your final Rust binary. It works on Windows, macOS, and Linux with no extra setup.

Final Output:

rusqlite Linking Error Solved

Optional Extensions

If you want to practice more, try:

  • Adding a priority field (integer or enum)
  • Sorting tasks by creation time or priority
  • Showing only incomplete tasks by default
  • Storing the database path in a config file

Summary

This project shows how to combine clap for command parsing and rusqlite for persistent data. With just a few functions, you have a real, usable CLI app that saves and retrieves information, without any external servers or tools.

Aditya Gupta
Aditya Gupta

Aditya Gupta is a founding member and editor at CodeForGeek. He first found his way into tech by reading articles, and now writes approachable guides to Node.js security, authentication, AI tools, coding agents, and web scraping.

Articles: 529