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:

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:

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.
Rust Intermediate Concepts
- Generic Types in Functions and Structs
- Implementing Traits for Custom Behavior in Rust
- Trait Bounds and Constraints in Rust
- Lifetimes in Rust
- Using Closures (Anonymous Functions) in Rust
- The Iterator Trait and .next() in Rust
- Higher Order Iterator Methods: map, filter, and fold in Rust
- Using impl Trait for Simplicity in Rust
- Advanced Collections: HashSet and BTreeMap in Rust
- Custom Error Types and the Error Trait in Rust
- Option and Result Combinators in Rust
- Writing Unit Tests in Rust
- Integration Testing in Rust
- Logging in Rust with the log Crate
- Cargo Tips and Tricks for Rust Projects
- CLI Argument Parsing with Clap in Rust
- File I/O and File System Operations in Rust
- Running External Commands in Rust
- Make HTTP Requests in Rust with Reqwest
- JSON Serialization and Deserialization in Rust with Serde
- Building a Weather CLI in Rust
- Date and Time Handling in Rust with Chrono
- Using Regular Expressions in Rust with the regex Crate
- Memory Management in Rust
- Understanding Borrow Checker Errors in Rust
- Interacting with Databases in Rust
- Building a Todo List CLI in Rust with SQLite
- Concurrency in Rust





