Compare commits

..

No commits in common. "e188fec99cd56e9ba71248ebccf1865e20267a99" and "c2b7c12905faf0321fe2c801d13abc7a8f6ba7ff" have entirely different histories.

13 changed files with 15 additions and 1923 deletions

1
.gitignore vendored
View file

@ -1 +0,0 @@
.claude/settings.local.json

2
backend/.gitignore vendored
View file

@ -1,3 +1 @@
/target
*.db

1577
backend/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,13 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.99"
axum = "0.8.4"
chrono = "0.4.41"
serde = "1.0.219"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
uuid = { version = "1.18.0", features = ["v4"] }

View file

@ -1,2 +0,0 @@
-- Add down migration script here
DROP TABLE tasks

View file

@ -1,10 +0,0 @@
-- Add up migration script here
CREATE TABLE tasks (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);

View file

@ -1,45 +0,0 @@
use anyhow::Result;
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
use std::str::FromStr;
/// Database configuration
pub struct DatabaseConfig {
pub database_url: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
database_url: "sqlite:local.db".to_string(),
}
}
}
/// Create a SQLx connection pool
pub async fn create_pool(config: &DatabaseConfig) -> Result<SqlitePool> {
let options = SqliteConnectOptions::from_str(&config.database_url)?.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
/// Initialize database with connection pool
pub async fn initialize_database() -> Result<SqlitePool> {
let config = DatabaseConfig::default();
create_pool(&config).await
}
#[cfg(test)]
pub async fn create_test_pool() -> Result<SqlitePool> {
let options = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

View file

@ -1,3 +0,0 @@
pub mod connection;
pub use connection::*;

View file

@ -1,8 +1,5 @@
use axum::{Router, routing::get};
mod models;
mod database;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();

View file

@ -1,3 +0,0 @@
mod task;
pub use task::*;

View file

@ -1,269 +0,0 @@
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "lowercase")]
pub enum TaskStatus {
Todo,
Done,
Backlog,
}
#[derive(sqlx::FromRow)]
pub struct TaskModel {
pub id: Uuid,
pub title: String,
pub description: Option<String>,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl TaskModel {
pub async fn insert(
pool: &SqlitePool,
title: &str,
description: Option<&str>,
) -> Result<TaskModel> {
if title.len() == 0 {
bail!("Title must not be empty");
}
let id = Uuid::new_v4();
let result = sqlx::query_as(
"INSERT INTO tasks (id, title, description) VALUES ($1, $2, $3) RETURNING *",
)
.bind(id)
.bind(title)
.bind(description)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result<TaskModel> {
let result = sqlx::query_as("SELECT * FROM tasks WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn update(self, pool: &SqlitePool) -> Result<TaskModel> {
let now: DateTime<Utc> = Utc::now();
let _ = sqlx::query(
"UPDATE tasks
SET title=$1, description=$2, status=$3, updated_at=$4, completed_at=$5
WHERE id=$6",
)
.bind(&self.title)
.bind(&self.description)
.bind(&self.status)
.bind(now)
.bind(&self.completed_at)
.bind(&self.id)
.execute(pool)
.await?;
TaskModel::get_by_id(pool, self.id).await
}
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<TaskModel>> {
let tasks = sqlx::query_as("SELECT * FROM tasks ORDER BY created_at DESC")
.fetch_all(pool)
.await?;
Ok(tasks)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::create_test_pool;
async fn setup_test_db() -> SqlitePool {
create_test_pool()
.await
.expect("Failed to create test pool")
}
#[tokio::test]
async fn test_insert_task() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = "Test Description";
let task = TaskModel::insert(&pool, title, Some(description))
.await
.expect("Failed to insert task");
assert_eq!(task.title, title);
assert_eq!(task.description, Some(description.to_string()));
assert_eq!(task.status, TaskStatus::Todo);
assert!(task.completed_at.is_none());
}
#[tokio::test]
async fn test_insert_task_with_null_description() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = None;
let task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
assert_eq!(task.title, title);
assert_eq!(task.description, None);
assert_eq!(task.status, TaskStatus::Todo);
}
#[tokio::test]
async fn test_get_by_id_found() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = Some("Test Description");
let inserted_task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
let retrieved_task = TaskModel::get_by_id(&pool, inserted_task.id)
.await
.expect("Failed to get task by id");
assert_eq!(retrieved_task.id, inserted_task.id);
assert_eq!(retrieved_task.title, inserted_task.title);
assert_eq!(retrieved_task.description, inserted_task.description);
}
#[tokio::test]
async fn test_get_by_id_not_found() {
let pool = setup_test_db().await;
let non_existent_id = Uuid::new_v4();
let result = TaskModel::get_by_id(&pool, non_existent_id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_update_task() {
let pool = setup_test_db().await;
let title = "Original Task";
let description = Some("Original Description");
let mut task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
// Update the task
task.title = "Updated Task".to_string();
task.description = Some("Updated Description".to_string());
task.status = TaskStatus::Done;
task.completed_at = Some(Utc::now());
let updated_task = task.update(&pool).await.expect("Failed to update task");
assert_eq!(updated_task.title, "Updated Task");
assert_eq!(
updated_task.description,
Some("Updated Description".to_string())
);
assert_eq!(updated_task.status, TaskStatus::Done);
assert!(updated_task.completed_at.is_some());
assert!(updated_task.updated_at > updated_task.created_at);
}
#[tokio::test]
async fn test_delete_task() {
let pool = setup_test_db().await;
let title = "Task to Delete";
let description = Some("This will be deleted");
let task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
// Delete the task
TaskModel::delete(&pool, task.id)
.await
.expect("Failed to delete task");
// Verify it's gone
let result = TaskModel::get_by_id(&pool, task.id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_list_all_tasks() {
let pool = setup_test_db().await;
// Insert multiple tasks
let _task1 = TaskModel::insert(&pool, "Task 1", Some("Desc 1"))
.await
.expect("Failed to insert task 1");
let _task2 = TaskModel::insert(&pool, "Task 2", None)
.await
.expect("Failed to insert task 2");
let tasks = TaskModel::list_all(&pool)
.await
.expect("Failed to list tasks");
assert_eq!(tasks.len(), 2);
// Check that both tasks are present (order may vary due to timing)
let titles: Vec<&String> = tasks.iter().map(|t| &t.title).collect();
assert!(titles.contains(&&"Task 1".to_string()));
assert!(titles.contains(&&"Task 2".to_string()));
}
#[tokio::test]
async fn test_task_status_serialization() {
let pool = setup_test_db().await;
// Test each status
let statuses = vec![TaskStatus::Todo, TaskStatus::Done, TaskStatus::Backlog];
for status in statuses {
let mut task = TaskModel::insert(&pool, "Test Task", None)
.await
.expect("Failed to insert task");
task.status = status;
task = task.update(&pool).await.expect("Failed to update task");
assert_eq!(task.status, status);
}
}
#[tokio::test]
async fn test_empty_title_constraint() {
let pool = setup_test_db().await;
let result = TaskModel::insert(&pool, "", None).await;
assert!(result.is_err());
}
}

View file

@ -1,8 +1,6 @@
# Temporary until we have a frontend.
set working-directory := 'backend'
export DATABASE_URL :="sqlite://local.db"
dev:
cargo run
@ -22,14 +20,3 @@ clean:
cargo clean
reset-db:
sqlx database drop
sqlx database create
sqlx migrate run
migrate:
sqlx migrate run
migrate-revert:
sqlx migrate revert

View file

@ -69,7 +69,7 @@ captains-log/
## Phase 2: Core Database Layer (Days 3-4)
### Task 2.0: Create Initial Database Migration
- [x] **File**: `backend/migrations/001_create_tasks.sql`
- [ ] **File**: `backend/migrations/001_create_tasks.sql`
- Create tasks table with all required fields
- Add proper indexes and constraints
- Include created_at, updated_at triggers if needed
@ -77,7 +77,7 @@ captains-log/
- **Expected outcome**: `sqlx migrate run` creates table successfully
### Task 2.1: Define Task Model
- [x] **File**: `backend/src/database/models.rs`
- [ ] **File**: `backend/src/database/models.rs`
- Create Task struct with SQLx derives
- Add TaskStatus and Priority enums
- Implement proper serialization/deserialization