├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── README.md ├── src ├── db.rs └── main.rs └── templates ├── index.html.hbs ├── todo-cards.html.hbs ├── todo-edit.html.hbs └── todo-read.html.hbs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sqlite.db 2 | .idea/ 3 | 4 | # Generated by Cargo 5 | # will have compiled files and executables 6 | debug/ 7 | target/ 8 | 9 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 10 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 11 | Cargo.lock 12 | 13 | # These are backup files generated by rustfmt 14 | **/*.rs.bk 15 | 16 | # MSVC Windows builds of rustc generate these, which store debugging information 17 | *.pdb 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example-todo-app-rust-htmx" 3 | version = "1.0.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | 8 | # db 9 | sqlx = { version = "0.8.3", features = ["runtime-async-std", "sqlite"] } 10 | 11 | # web framework 12 | rocket = "0.5.1" 13 | 14 | # templating 15 | rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] } 16 | serde = { version = "1.0.217", features = ["derive"] } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example TODO app with Rust and htmx 2 | 3 | A simple example TODO app build using: 4 | 5 | - [htmx](https://htmx.org/) 6 | - [Rust](https://www.rust-lang.org/) 7 | - [Rocket](https://rocket.rs/) web framework using handlebars templates 8 | - [SQLx](https://github.com/launchbadge/sqlx) and [SQLite](https://sqlite.org/) 9 | 10 | ## Run 11 | 12 | ```shell 13 | cargo run 14 | ``` 15 | 16 | ## Hot Reloading 17 | 18 | Install cargo watch with `cargo install cargo-watch` then use: 19 | 20 | ```shell 21 | cargo watch -x run 22 | ``` 23 | 24 | ## Syntax Check 25 | 26 | ```shell 27 | cargo clippy 28 | ``` 29 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use crate::DB_URL; 2 | use serde::Serialize; 3 | use sqlx::migrate::MigrateDatabase; 4 | use sqlx::{Error, Pool, Sqlite, SqlitePool}; 5 | 6 | #[derive(Serialize)] 7 | pub struct Todo { 8 | id: i64, 9 | title: String, 10 | completed: bool, 11 | } 12 | 13 | async fn conn() -> Result, sqlx::Error> { 14 | SqlitePool::connect(DB_URL).await 15 | } 16 | 17 | pub async fn maybe_create_database() -> Result<(), Error> { 18 | if !Sqlite::database_exists(DB_URL).await.unwrap_or(false) { 19 | info!("Creating database {}", DB_URL); 20 | Sqlite::create_database(DB_URL).await? 21 | } else { 22 | info!("Database already exists"); 23 | } 24 | sqlx::query( 25 | " 26 | CREATE TABLE IF NOT EXISTS todos ( 27 | id INTEGER PRIMARY KEY, 28 | title TEXT NOT NULL, 29 | completed INTEGER 30 | ) 31 | ", 32 | ) 33 | .execute(&conn().await?) 34 | .await?; 35 | Ok(()) 36 | } 37 | 38 | pub async fn add_todo(title: &String) -> Result { 39 | let res = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, 0)") 40 | .bind(title) 41 | .execute(&conn().await?) 42 | .await?; 43 | info!("Todo added with id {:?}", res.last_insert_rowid()); 44 | Ok(res.last_insert_rowid()) 45 | } 46 | 47 | pub async fn get_todo(id: i64) -> Result { 48 | let row: (i64, String, i8) = 49 | sqlx::query_as("SELECT id, title, completed FROM todos WHERE id=?") 50 | .bind(id) 51 | .fetch_one(&conn().await?) 52 | .await?; 53 | Ok(Todo { 54 | id: row.0, 55 | title: row.1, 56 | completed: row.2 == 1, 57 | }) 58 | } 59 | 60 | pub async fn update_todo(id: i64, title: &String) -> Result<(), DbError> { 61 | sqlx::query( 62 | "UPDATE todos SET title = ? WHERE id=?", 63 | ) 64 | .bind(title) 65 | .bind(id) 66 | .execute(&conn().await?) 67 | .await?; 68 | Ok(()) 69 | } 70 | 71 | pub async fn toggle_todo_completed(id: i64) -> Result<(), DbError> { 72 | sqlx::query( 73 | "UPDATE todos SET completed = \ 74 | CASE WHEN completed = 1 THEN 0 \ 75 | ELSE 1 76 | END 77 | WHERE id=?", 78 | ) 79 | .bind(id) 80 | .execute(&conn().await?) 81 | .await?; 82 | Ok(()) 83 | } 84 | 85 | pub async fn clear_completed() -> Result<(), DbError> { 86 | sqlx::query("DELETE FROM todos where completed = 1") 87 | .execute(&conn().await?) 88 | .await?; 89 | Ok(()) 90 | } 91 | 92 | pub async fn get_todos() -> Result, DbError> { 93 | let rows: Vec<(i64, String, i8)> = sqlx::query_as("SELECT id, title, completed FROM todos ORDER BY id DESC") 94 | .fetch_all(&conn().await?) 95 | .await?; 96 | Ok(rows 97 | .iter() 98 | .map(|row| Todo { 99 | id: row.0, 100 | title: row.1.clone(), 101 | completed: row.2 == 1, 102 | }) 103 | .collect::>()) 104 | } 105 | 106 | 107 | pub struct DbError; 108 | 109 | impl From for DbError { 110 | fn from(_: Error) -> Self { 111 | DbError 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rocket; 3 | 4 | use rocket::form::Form; 5 | use rocket::http::Status; 6 | use rocket_dyn_templates::{context, Template}; 7 | 8 | use crate::db::{add_todo, clear_completed, DbError, get_todo, get_todos, maybe_create_database, toggle_todo_completed, update_todo}; 9 | 10 | mod db; 11 | 12 | const DB_URL: &str = "sqlite://sqlite.db"; 13 | 14 | #[rocket::main] 15 | async fn main() -> Result<(), rocket::Error> { 16 | maybe_create_database().await.expect("Failed to create DB"); 17 | 18 | let _rocket = rocket::build() 19 | .attach(Template::fairing()) 20 | .mount( 21 | "/", 22 | routes![ 23 | get_index, 24 | post_todos, 25 | get_todo_read, 26 | get_todo_edit, 27 | post_todo_edit, 28 | post_todo_complete, 29 | post_todo_clear_completed 30 | ], 31 | ) 32 | .launch() 33 | .await?; 34 | Ok(()) 35 | } 36 | 37 | #[get("/")] 38 | async fn get_index() -> Result { 39 | let todos = get_todos().await?; 40 | Ok(Template::render( 41 | "index", 42 | context! { 43 | todos 44 | }, 45 | )) 46 | } 47 | 48 | #[derive(FromForm)] 49 | struct TodoForm { 50 | title: String, 51 | } 52 | 53 | #[post("/todos", data = "
")] 54 | async fn post_todos(form: Form) -> Result { 55 | let id = add_todo(&form.title).await?; 56 | let todo = get_todo(id).await?; 57 | Ok(Template::render( 58 | "todo-read", 59 | context! { 60 | todo 61 | }, 62 | )) 63 | } 64 | 65 | #[post("/todo-edit/", data = "")] 66 | async fn post_todo_edit(id: i64, form: Form) -> Result { 67 | update_todo(id, &form.title).await?; 68 | let todo = get_todo(id).await?; 69 | Ok(Template::render( 70 | "todo-read", 71 | context! { 72 | todo 73 | }, 74 | )) 75 | } 76 | 77 | #[get("/todo-edit/")] 78 | async fn get_todo_edit(id: i64) -> Result { 79 | let todo = get_todo(id).await?; 80 | Ok(Template::render( 81 | "todo-edit", 82 | context! { 83 | todo 84 | }, 85 | )) 86 | } 87 | 88 | #[get("/todo-read/")] 89 | async fn get_todo_read(id: i64) -> Result { 90 | let todo = get_todo(id).await?; 91 | Ok(Template::render( 92 | "todo-read", 93 | context! { 94 | todo 95 | }, 96 | )) 97 | } 98 | 99 | #[post("/todo-complete/")] 100 | async fn post_todo_complete(id: i64) -> Result { 101 | toggle_todo_completed(id).await?; 102 | let todo = get_todo(id).await?; 103 | Ok(Template::render( 104 | "todo-read", 105 | context! { 106 | todo 107 | }, 108 | )) 109 | } 110 | 111 | #[post("/todos-clear-completed")] 112 | async fn post_todo_clear_completed() -> Result { 113 | clear_completed().await?; 114 | let todos = get_todos().await?; 115 | Ok(Template::render( 116 | "todo-cards", 117 | context! { 118 | todos 119 | }, 120 | )) 121 | } 122 | 123 | impl From for Status { 124 | fn from(_: DbError) -> Self { 125 | Status::InternalServerError 126 | } 127 | } 128 | 129 | -------------------------------------------------------------------------------- /templates/index.html.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rust HTMX TODO 6 | 8 | 9 | 11 | 24 | 25 | 26 |
27 |
28 |

Rust HTMX TODO

29 | 30 | 32 |
33 | 37 |
38 | 39 | 40 |
41 | {{> todo-cards todos=todos }} 42 |
43 | 44 | 50 |
51 | 52 |
53 | 54 | 55 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /templates/todo-cards.html.hbs: -------------------------------------------------------------------------------- 1 | {{#each todos}} 2 | {{> todo-read todo=this }} 3 | {{/each}} -------------------------------------------------------------------------------- /templates/todo-edit.html.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
8 |
9 | 13 | 17 |
18 |
19 |
20 |
21 |
-------------------------------------------------------------------------------- /templates/todo-read.html.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 9 |
10 |
11 | 19 | 23 |
24 |
25 |
26 |
27 | --------------------------------------------------------------------------------