Files
rust-todo/src/main.rs
T
2026-10-23 11:20:20 -07:00

216 lines
6.8 KiB
Rust

use std::env;
use std::sync::Mutex;
#[macro_use] extern crate rocket;
use rocket::State;
use rocket::form::Form;
use rocket::response::content::RawHtml;
use maud::{html, DOCTYPE};
#[derive(Clone)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
/// In-memory store for todos, protected by a Mutex for thread-safe access.
/// Rocket handlers access it via `&State<TodoStore>`.
struct TodoStore {
todos: Mutex<Vec<Todo>>,
next_id: Mutex<u32>,
}
impl TodoStore {
fn new() -> Self {
TodoStore {
todos: Mutex::new(Vec::new()),
next_id: Mutex::new(1),
}
}
/// Adds a new todo and returns a clone of it (so the handler can render it).
fn add(&self, title: String) -> Todo {
let mut todos = self.todos.lock().unwrap();
let mut next_id = self.next_id.lock().unwrap();
let todo = Todo {
id: *next_id,
title,
completed: false,
};
*next_id += 1;
todos.push(Todo {
id: todo.id,
title: todo.title.clone(),
completed: todo.completed,
});
todo
}
/// Toggles the completed status of a todo by ID. Returns the updated todo if found.
fn toggle(&self, id: u32) -> Option<Todo> {
let mut todos = self.todos.lock().unwrap();
todos.iter_mut().find(|t| t.id == id).map(|todo| {
todo.completed = !todo.completed;
Todo {
id: todo.id,
title: todo.title.clone(),
completed: todo.completed,
}
})
}
/// Deletes a todo by ID. Returns true if something was removed.
fn delete(&self, id: u32) -> bool {
let mut todos = self.todos.lock().unwrap();
let len_before = todos.len();
todos.retain(|t| t.id != id);
todos.len() < len_before
}
/// Returns a snapshot (cloned Vec) of all todos for rendering.
fn list(&self) -> Vec<Todo> {
self.todos.lock().unwrap().clone()
}
}
/// Takes a slice of todos and generates the full HTML String response,
/// including a new-todo input form and HTMX triggers for toggling/deleting.
fn render_todo_page(todos: &[Todo]) -> String {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { "Rust + HTMX + Tailwind Todos" }
script src="https://cdn.tailwindcss.com" {}
script src="https://unpkg.com/[email protected]" {}
}
body class="bg-slate-50 text-slate-800 min-h-screen flex items-center justify-center" {
div class="max-w-md w-full bg-white rounded-xl shadow-md p-6 m-4" {
h1 class="text-2xl font-bold text-slate-900 mb-6 text-center" { "My Todo List" }
// 1. Input Form to create a new todo
form
hx-post="/todos"
hx-target="#todo-list"
hx-swap="beforeend"
class="flex gap-2 mb-6"
{
input
type="text"
name="title"
placeholder="Add a new todo..."
required
class="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm";
button
type="submit"
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
{
"Add"
}
}
// 2. Todo List Container
div id="todo-list" class="space-y-3" {
@for todo in todos {
(render_todo_item(todo))
}
}
}
}
}
}.into_string()
}
/// Renders an individual todo item (useful to reuse when returning a single updated item via HTMX)
fn render_todo_item(todo: &Todo) -> maud::Markup {
let text_style = if todo.completed {
"line-through text-slate-400"
} else {
"text-slate-700"
};
html! {
div class="flex items-center justify-between p-3 bg-slate-100 rounded-lg border border-slate-200 todo" {
div class="flex items-center space-x-3" {
input
type="checkbox"
checked[todo.completed]
// HTMX attribute to toggle completion on the server
hx-patch=(format!("/todos/{}/toggle", todo.id))
hx-target="closest div.todo"
hx-swap="outerHTML"
class="w-4 h-4 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer";
span class=(text_style) {
(todo.title)
}
}
button
class="text-red-500 hover:text-red-700 text-sm font-medium transition-colors"
hx-delete=(format!("/todos/{}", todo.id))
hx-target="closest div.flex"
hx-swap="outerHTML"
{
"Delete"
}
}
}
}
#[get("/")]
fn index(store: &State<TodoStore>) -> RawHtml<String> {
let todos = store.list();
RawHtml(render_todo_page(&todos))
}
#[derive(FromForm)]
struct CreateRequest {
title: String,
}
#[post("/todos", data = "<request>")]
fn create_todo(request: Form<CreateRequest>, store: &State<TodoStore>) -> RawHtml<String> {
let todo = store.add(request.title.clone());
RawHtml(render_todo_item(&todo).into_string())
}
#[patch("/todos/<id>/toggle")]
fn toggle_todo(id: u32, store: &State<TodoStore>) -> RawHtml<String> {
match store.toggle(id) {
Some(todo) => RawHtml(render_todo_item(&todo).into_string()),
None => RawHtml(String::new()),
}
}
#[delete("/todos/<id>")]
fn delete_todo(id: u32, store: &State<TodoStore>) -> RawHtml<String> {
store.delete(id);
RawHtml(String::new())
}
#[launch]
fn rocket() -> _ {
let args: Vec<String> = env::args().collect();
let program = &args[0];
println!("Program: {}", program);
if let Some(command) = args.get(1) {
println!("Command: {}", command);
match command.as_str() {
"add" => {
println!("command");
// parse -m flag value
},
_ => todo!()
}
}
rocket::build()
.manage(TodoStore::new())
.mount("/", routes![index, create_todo, delete_todo, toggle_todo])
}