Why Another Todo App?
I know what you're thinking: "Another todo app tutorial? Really?" But hear me out. This isn't just about building a todo appβit's about exploring how Rust's type system, ownership model, and ecosystem enable you to build one application with three completely different interfaces while maintaining clean architecture, comprehensive testing, and zero code duplication.
π¦ Full source code available on GitHub
By the end of this post, you'll understand how to:
- Build beautiful terminal UIs with Ratatui
- Create type-safe REST APIs with Axum
- Design CLI tools with Clap
- Share business logic across multiple interfaces
- Write comprehensive tests (67 tests and counting!)
- Deploy production-ready Rust applications
The Challenge: Three Interfaces, One Codebase
Imagine you're building a todo application. Your users have different needs:
-
Developers want a quick CLI tool for scripting:
todo add "Review PR #42" - Power users prefer a rich TUI for keyboard-driven workflows
- Applications need a REST API for integration
Typically, you'd build three separate applications. But what if you could build all three interfaces while writing the business logic exactly once?
That's the challenge I set out to solve.
The Result
The final application includes:
1. π₯οΈ TUI Client - Interactive Terminal Interface
A beautiful, keyboard-driven interface built with Ratatui:
cargo run --release --bin todo-tui
Features:
- Vim-style navigation (j/k for movement)
- Modal editing (i/a to add todos, Esc to cancel)
- Visual feedback (strikethrough for completed items)
- Real-time state synchronization
- Proper terminal cleanup on exit
2. π HTTP Server - REST API
A production-ready API server built with Axum:
# Start the server
cargo run --bin todo-server
# Create a todo
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Deploy to production"}'
# List all todos
curl http://localhost:3000/todos
# Update a todo
curl -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Deploy to production","completed":true}'
Features:
- RESTful design with proper HTTP status codes
- Thread-safe database access with
Arc<Mutex<Database>> - JSON serialization with serde
- CORS support for web applications
- Health check endpoint for monitoring
- Environment-based configuration
3. β‘ CLI Client - Scriptable Command-Line Tool
A fast, scriptable CLI built with Clap:
# Add a todo
cargo run --bin todo-cli -- add "Write blog post"
# List all todos
cargo run --bin todo-cli -- list
# Toggle completion
cargo run --bin todo-cli -- toggle 1
# Update title
cargo run --bin todo-cli -- update 1 "Publish blog post"
# Delete
cargo run --bin todo-cli -- delete 1
Perfect for shell scripts, cron jobs, and automation workflows.
Architecture: The Secret Sauce
The magic isn't in any single interfaceβit's in how they all share the same foundation. Here's the three-layer architecture that makes it possible:
Layer 1: Database Layer (db.rs)
The foundation uses SQLite with rusqlite for type-safe database access:
pub struct Database {
conn: Connection,
}
pub struct Todo {
pub id: i32,
pub title: String,
pub completed: bool,
}
impl Database {
pub fn new(path: &str) -> rusqlite::Result<Self> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0
)",
[],
)?;
Ok(Database { conn })
}
pub fn add_todo(&self, title: &str) -> rusqlite::Result<i32> {
if title.trim().is_empty() {
return Err(rusqlite::Error::InvalidParameterName(
"Title cannot be empty".to_string()
));
}
self.conn.execute(
"INSERT INTO todos (title, completed) VALUES (?1, 0)",
params![title],
)?;
Ok(self.conn.last_insert_rowid() as i32)
}
pub fn get_todos(&self) -> rusqlite::Result<Vec<Todo>> {
let mut stmt = self.conn.prepare(
"SELECT id, title, completed FROM todos ORDER BY id"
)?;
let todos = stmt.query_map([], |row| {
Ok(Todo {
id: row.get(0)?,
title: row.get(1)?,
completed: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(todos)
}
pub fn toggle_todo(&self, id: i32) -> rusqlite::Result<()> {
let rows = self.conn.execute(
"UPDATE todos SET completed = NOT completed WHERE id = ?1",
params![id],
)?;
if rows == 0 {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
// ... delete_todo, update_todo, get_todo_by_id methods
}
Key Design Decisions:
- β Single responsibility: Only handles persistence, no UI concerns
- β Input validation: Rejects empty titles at the database boundary
- β
Error handling: Returns
rusqlite::Resultfor proper error propagation - β Type safety: Rust's type system prevents SQL injection and type mismatches
Layer 2: Application Layer (app.rs)
The business logic layer manages state and coordinates database operations:
pub enum InputMode {
Normal,
Editing,
}
pub struct App {
pub db: Database,
pub todos: Vec<Todo>,
pub list_state: ListState,
pub input: String,
pub input_mode: InputMode,
}
impl App {
pub fn new(db_path: &str) -> Result<Self, Box<dyn Error>> {
let db = Database::new(db_path)?;
let mut app = App {
db,
todos: Vec::new(),
list_state: ListState::default(),
input: String::new(),
input_mode: InputMode::Normal,
};
app.refresh_todos()?;
Ok(app)
}
pub fn add_todo(&mut self, title: &str) -> Result<(), Box<dyn Error>> {
self.db.add_todo(title)?;
self.refresh_todos()?; // Critical: reload and update UI state
Ok(())
}
pub fn refresh_todos(&mut self) -> Result<(), Box<dyn Error>> {
self.todos = self.db.get_todos()?;
// Adjust selection if list changed
if self.todos.is_empty() {
self.list_state.select(None);
} else if let Some(i) = self.list_state.selected() {
if i >= self.todos.len() {
self.list_state.select(Some(self.todos.len() - 1));
}
}
Ok(())
}
pub fn next(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.todos.len() - 1 {
0 // Wrap to start
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
// ... previous, toggle_selected, delete_selected methods
}
The State Synchronization Pattern:
This is the most critical pattern in the entire application. After any mutation (add, update, delete, toggle), we call refresh_todos() to reload from the database. Why?
- Single source of truth: The database is authoritative
- Prevents state drift: In-memory state always matches persistence
- Simplifies logic: No need to manually update arrays
- Handles edge cases: Selection adjustment happens in one place
Layer 3: UI Layer (ui.rs)
Pure rendering functions using Ratatui:
pub fn render(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title bar
Constraint::Min(1), // Todo list
Constraint::Length(3), // Status bar
])
.split(f.area());
render_title(f, chunks[0]);
render_todo_list(f, chunks[1], app);
render_status_bar(f, chunks[2], app);
}
fn render_todo_list(f: &mut Frame, area: Rect, app: &mut App) {
let items: Vec<ListItem> = app
.todos
.iter()
.map(|todo| {
let checkbox = if todo.completed { "[x]" } else { "[ ]" };
let content = format!("{} {}", checkbox, todo.title);
let style = if todo.completed {
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default()
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Todos"))
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
);
f.render_stateful_widget(list, area, &mut app.list_state);
}
Pure UI Philosophy:
- No business logic in rendering code
- Takes
&Appor&mut Appfor stateful widgets only - Easy to modify without breaking functionality
- Clear visual feedback for all states
The Power of Shared Architecture
Here's where it gets interesting. Each interface uses exactly what it needs:
CLI: Database Layer Only
// src/bin/cli.rs
use clap::{Parser, Subcommand};
use todo_app::db::Database;
#[derive(Parser)]
struct Cli {
#[arg(long, env = "TODO_DB_PATH", default_value = "./tmp/todos.db")]
db_path: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Add { title: String },
List,
Toggle { id: i32 },
Delete { id: i32 },
// ... more commands
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let db = Database::new(&cli.db_path)?;
match cli.command {
Commands::Add { title } => {
let id = db.add_todo(&title)?;
println!("Added todo #{}", id);
}
Commands::List => {
let todos = db.get_todos()?;
for todo in todos {
let status = if todo.completed { "β" } else { " " };
println!("[{}] {} - {}", status, todo.id, todo.title);
}
}
Commands::Toggle { id } => {
db.toggle_todo(id)?;
println!("Toggled todo #{}", id);
}
// ... handle other commands
}
Ok(())
}
Clean and simple: The CLI doesn't need UI state, so it talks directly to the database.
Server: Database Layer + Thread Safety
// src/bin/server.rs
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use todo_app::db::Database;
type SharedDatabase = Arc<Mutex<Database>>;
#[derive(Serialize, Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Serialize, Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
async fn create_todo(
State(db): State<SharedDatabase>,
Json(payload): Json<CreateTodo>,
) -> Result<(StatusCode, Json<i32>), ApiError> {
let db = db.lock().unwrap();
let id = db.add_todo(&payload.title)?;
Ok((StatusCode::CREATED, Json(id)))
}
async fn list_todos(
State(db): State<SharedDatabase>,
) -> Result<Json<Vec<Todo>>, ApiError> {
let db = db.lock().unwrap();
let todos = db.get_todos()?;
Ok(Json(todos))
}
#[tokio::main]
async fn main() {
let db_path = std::env::var("TODO_DB_PATH")
.unwrap_or_else(|_| "./tmp/todos.db".to_string());
let db = Arc::new(Mutex::new(
Database::new(&db_path).expect("Failed to initialize database")
));
let app = Router::new()
.route("/health", get(health_check))
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.layer(CorsLayer::permissive())
.with_state(db);
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("127.0.0.1:{}", port);
println!("Server running on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Thread safety without overhead: Arc<Mutex<Database>> provides safe concurrent access. The mutex is uncontended in practice since SQLite operations are fast.
TUI: Full Stack
The TUI uses all three layersβdatabase, application logic, and UI renderingβfor the complete interactive experience.
Testing: 67 Tests and Counting
One of my favorite parts of this project is the comprehensive test suite. Testing is a first-class concern:
Unit Tests (23 tests)
Embedded in each module with #[cfg(test)]:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_todo() {
let db = Database::new(":memory:").unwrap();
let id = db.add_todo("Test todo").unwrap();
assert_eq!(id, 1);
let todos = db.get_todos().unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0].title, "Test todo");
assert!(!todos[0].completed);
}
#[test]
fn test_empty_title_rejected() {
let db = Database::new(":memory:").unwrap();
assert!(db.add_todo("").is_err());
assert!(db.add_todo(" ").is_err());
}
#[test]
fn test_toggle_nonexistent_todo() {
let db = Database::new(":memory:").unwrap();
assert!(db.toggle_todo(999).is_err());
}
// 20 more unit tests...
}
Integration Tests (44 tests)
Separate tests/ directory with four comprehensive test files:
tests/database_integration.rs - Tests persistence and concurrency:
#[test]
fn test_concurrent_database_access() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_str().unwrap();
// Create two database connections
let db1 = Database::new(path).unwrap();
let db2 = Database::new(path).unwrap();
// Write from first connection
db1.add_todo("Todo from connection 1").unwrap();
// Read from second connection
let todos = db2.get_todos().unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0].title, "Todo from connection 1");
}
#[test]
fn test_sql_injection_prevention() {
let db = Database::new(":memory:").unwrap();
// Attempt SQL injection
let malicious_title = "'; DROP TABLE todos; --";
db.add_todo(malicious_title).unwrap();
// Verify table still exists and contains the malicious string as data
let todos = db.get_todos().unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0].title, malicious_title);
}
#[test]
fn test_large_dataset_performance() {
let db = Database::new(":memory:").unwrap();
// Add 1000 todos
for i in 0..1000 {
db.add_todo(&format!("Todo {}", i)).unwrap();
}
// Verify retrieval is fast
let start = std::time::Instant::now();
let todos = db.get_todos().unwrap();
let duration = start.elapsed();
assert_eq!(todos.len(), 1000);
assert!(duration.as_millis() < 100, "Query took too long: {:?}", duration);
}
tests/app_workflow.rs - Tests end-to-end workflows:
#[test]
fn test_complete_workflow() {
let mut app = App::new(":memory:").unwrap();
// Start in normal mode
assert!(matches!(app.input_mode, InputMode::Normal));
// Add first todo
app.add_todo("First todo").unwrap();
assert_eq!(app.todos.len(), 1);
// Add second todo
app.add_todo("Second todo").unwrap();
assert_eq!(app.todos.len(), 2);
// Navigate and toggle
app.next();
app.toggle_selected().unwrap();
assert!(app.todos[1].completed);
// Delete selected
app.delete_selected().unwrap();
assert_eq!(app.todos.len(), 1);
assert_eq!(app.todos[0].title, "First todo");
}
#[test]
fn test_selection_adjustment_after_delete() {
let mut app = App::new(":memory:").unwrap();
// Add 3 todos
for i in 1..=3 {
app.add_todo(&format!("Todo {}", i)).unwrap();
}
// Select last item (index 2)
app.next();
app.next();
assert_eq!(app.list_state.selected(), Some(2));
// Delete it
app.delete_selected().unwrap();
// Selection should adjust to new last item (index 1)
assert_eq!(app.list_state.selected(), Some(1));
}
tests/server_api.rs - Tests API endpoints:
#[test]
fn test_api_validation() {
let db = Arc::new(Mutex::new(Database::new(":memory:").unwrap()));
// Test empty title rejection
let result = create_todo(
State(db.clone()),
Json(CreateTodo { title: "".to_string() }),
);
assert!(result.is_err());
}
#[test]
fn test_api_not_found() {
let db = Arc::new(Mutex::new(Database::new(":memory:").unwrap()));
let result = get_todo(State(db), Path(999));
assert!(matches!(result, Err(ApiError::NotFound)));
}
tests/cli_integration.rs - Tests CLI commands via process spawning:
#[test]
fn test_cli_workflow() {
let temp_file = NamedTempFile::new().unwrap();
let db_path = temp_file.path().to_str().unwrap();
// Add a todo
let output = Command::new("cargo")
.args(&["run", "--bin", "todo-cli", "--", "--db-path", db_path, "add", "Test todo"])
.output()
.unwrap();
assert!(output.status.success());
// List todos
let output = Command::new("cargo")
.args(&["run", "--bin", "todo-cli", "--", "--db-path", db_path, "list"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Test todo"));
}
The Power of :memory: Databases:
All tests use SQLite's in-memory databases for:
- β‘ Blazing fast execution
- π Perfect isolation (no shared state between tests)
- π§Ή No cleanup needed
- β Idempotent tests (same result every time)
Real-World Lessons
Building this project taught me several valuable lessons about Rust and software architecture:
1. Separation of Concerns Is Not Optional
The database layer knows nothing about UIs. The UI layer knows nothing about SQLite. This isn't just good practiceβit's what enables code reuse across three completely different interfaces.
2. The Type System Is Your Friend
Rust prevented entire categories of bugs:
- No null pointer exceptions (Option type forces handling)
- No use-after-free (ownership system prevents it)
- No data races (Mutex + Arc provides safe concurrency)
- No SQL injection (parameterized queries)
3. State Synchronization Must Be Deliberate
The refresh_todos() pattern is simple but powerful. After every mutation, reload from the database. This prevents an entire class of state drift bugs that plague many applications.
4. Testing Drives Design
Writing integration tests for all three interfaces revealed design issues early. The test suite gave me confidence to refactor aggressively.
5. In-Memory Databases Are a Superpower
Using :memory: SQLite databases for testing was a game-changer. Tests run in milliseconds, with perfect isolation, and zero cleanup code.
6. Rust Is Production-Ready
This isn't a toy. The application is production-ready with:
- Proper error handling at every layer
- Thread-safe concurrent access
- Comprehensive test coverage
- Clean terminal cleanup
- Environment-based configuration
- Clippy-clean code with all warnings enabled
Performance Characteristics
Let's talk about performance. The application is fast:
- Database operations: Sub-millisecond for typical datasets (<1000 todos)
- TUI rendering: 60 FPS with Ratatui's efficient diffing
- API response times: <5ms for most endpoints (local testing)
- CLI commands: Near-instant feedback
- Binary size: ~5MB release build (stripped)
- Memory usage: ~10MB RSS (TUI), ~15MB (Server)
The test suite demonstrates this:
#[test]
fn test_large_dataset_performance() {
let db = Database::new(":memory:").unwrap();
for i in 0..1000 {
db.add_todo(&format!("Todo {}", i)).unwrap();
}
let start = std::time::Instant::now();
let todos = db.get_todos().unwrap();
let duration = start.elapsed();
assert_eq!(todos.len(), 1000);
assert!(duration.as_millis() < 100); // Completes in <100ms
}
Real-World Use Cases
This architecture pattern isn't just for todo apps. Consider:
System Administration Tools
- TUI for interactive management
- CLI for scripting and automation
- API for web dashboards or monitoring
Database Management
- TUI for developers doing migrations
- CLI for CI/CD pipelines
- API for application integration
Log Analysis
- TUI for interactive exploration
- CLI for grep-like filtering
- API for visualization tools
DevOps Workflows
- TUI for interactive debugging
- CLI for shell scripts
- API for orchestration systems
Getting Started
Clone the repository and try all three interfaces:
git clone https://github.com/sebyx07/rust-ratatui-todos
cd rust-ratatui-todos
# Run tests
cargo test
# Try the TUI
cargo run --release --bin todo-tui
# Try the CLI
cargo run --bin todo-cli -- add "My first todo"
cargo run --bin todo-cli -- list
# Try the server
cargo run --bin todo-server
# In another terminal:
curl http://localhost:3000/todos
Project Structure
rust-ratatui-todos/
βββ src/
β βββ lib.rs # Library exports for testing
β βββ main.rs # TUI client entry point
β βββ db.rs # Database layer (Layer 1)
β βββ app.rs # Application layer (Layer 2)
β βββ ui.rs # UI layer (Layer 3)
β βββ bin/
β βββ server.rs # HTTP server
β βββ cli.rs # CLI client
βββ tests/
β βββ database_integration.rs
β βββ app_workflow.rs
β βββ server_api.rs
β βββ cli_integration.rs
βββ CLAUDE.md # Comprehensive documentation
βββ Cargo.toml # Dependencies and configuration
Key Dependencies
[dependencies]
# Database
rusqlite = { version = "0.33", features = ["bundled"] }
# TUI
ratatui = "0.29"
crossterm = "0.28"
# Server
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# CLI
clap = { version = "4", features = ["derive", "env"] }
What's Next?
Potential enhancements to explore:
- Search/Filter: Add text search across todos
- Categories/Tags: Organize todos with labels
- Due Dates: Add temporal organization
- Priorities: High/medium/low priority levels
- WebSocket Updates: Real-time sync in the server
- Export/Import: JSON/CSV data portability
- Undo/Redo: Command pattern for state history
- Persistence Layer Abstraction: Support PostgreSQL, MySQL
But the core lesson remains: clean architecture enables flexibility.
Conclusion
This project demonstrates that Rust is not just for systems programmingβit's excellent for building complete, production-ready applications with multiple interfaces.
Key takeaways:
- Layer your architecture properly - Separation of concerns enables code reuse
- Let the type system guide you - Rust prevents entire categories of bugs
- Test comprehensively - Integration tests catch real issues
- State synchronization is critical - Always reload after mutations
- Choose the right tools - Ratatui for TUIs, Axum for APIs, Clap for CLIs
The combination of Rust's safety guarantees, excellent libraries, and clean architecture patterns creates a powerful foundation for building reliable software that users can trust.
π¦ Explore the full source code on GitHub
Whether you're building a todo app, a database tool, a monitoring system, or anything else that needs multiple interfacesβthis architecture pattern will serve you well.
Happy coding! π¦
Top comments (0)