diff --git a/migrations/0001_initial.down.sql b/migrations/0001_initial.down.sql new file mode 100644 index 0000000..a93283a --- /dev/null +++ b/migrations/0001_initial.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS todo_tags; +DROP TABLE IF EXISTS todos; diff --git a/migrations/0001_initial.up.sql b/migrations/0001_initial.up.sql new file mode 100644 index 0000000..e9c7761 --- /dev/null +++ b/migrations/0001_initial.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'medium', + project TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + completed_at TEXT +); + +CREATE TABLE IF NOT EXISTS todo_tags ( + todo_id INTEGER NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (todo_id, tag) +); diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..d185e57 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,151 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; + +use crate::model::{Priority, Todo}; + +pub type Pool = SqlitePool; + +// resolve the global todo database path +pub fn db_path() -> Result { + if let Ok(path) = env::var("TODO_DB") { + return Ok(PathBuf::from(path)); + } + + let data_dir = dirs::data_dir() + .context("cannot determine data directory")? + .join("todo"); + + std::fs::create_dir_all(&data_dir) + .with_context(|| format!("cannot create {}", data_dir.display()))?; + + Ok(data_dir.join("todos.db")) +} + +pub async fn connect() -> Result { + let path = db_path()?; + + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .context("cannot open database")?; + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .context("cannot run migrations")?; + + Ok(pool) +} + +// detect git repo root by walking up from cwd +pub fn detect_project() -> Option { + let cwd = env::current_dir().ok()?; + let mut dir: &Path = &cwd; + + loop { + if dir.join(".git").exists() { + return Some(dir.to_string_lossy().into_owned()); + } + dir = dir.parent()?; + } +} + +fn priority_to_str(p: &Priority) -> &'static str { + match p { + Priority::Low => "low", + Priority::Medium => "medium", + Priority::High => "high", + Priority::Critical => "critical", + } +} + +pub async fn insert_todo( + pool: &Pool, + text: &str, + priority: &Priority, + project: Option<&str>, + tags: &[String], +) -> Result { + let pri_str = priority_to_str(priority); + + let row = sqlx::query_scalar::<_, i64>( + "INSERT INTO todos (text, priority, project) VALUES (?1, ?2, ?3) RETURNING id", + ) + .bind(text) + .bind(pri_str) + .bind(project) + .fetch_one(pool) + .await + .context("cannot insert todo")?; + + let id = row; + + for tag in tags { + sqlx::query("INSERT INTO todo_tags (todo_id, tag) VALUES (?1, ?2)") + .bind(id) + .bind(tag) + .execute(pool) + .await + .context("cannot insert tag")?; + } + + // fetch the full row back + fetch_todo(pool, id).await +} + +pub async fn fetch_todo(pool: &Pool, id: i64) -> Result { + let row = sqlx::query_as::<_, TodoRow>( + "SELECT id, text, priority, project, created_at, completed_at FROM todos WHERE id = ?1", + ) + .bind(id) + .fetch_optional(pool) + .await + .context("cannot fetch todo")? + .with_context(|| format!("todo {id} not found"))?; + + let tags = sqlx::query_scalar::<_, String>( + "SELECT tag FROM todo_tags WHERE todo_id = ?1 ORDER BY tag", + ) + .bind(id) + .fetch_all(pool) + .await + .context("cannot fetch tags")?; + + Ok(row.into_todo(tags)) +} + +// internal row type for sqlx FromRow +#[derive(sqlx::FromRow)] +struct TodoRow { + id: i64, + text: String, + priority: String, + project: Option, + created_at: chrono::DateTime, + completed_at: Option>, +} + +impl TodoRow { + fn into_todo(self, tags: Vec) -> Todo { + let priority = self.priority.parse().unwrap_or_default(); + Todo { + id: self.id, + text: self.text, + priority, + project: self.project, + tags, + created_at: self.created_at, + completed_at: self.completed_at, + } + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..79a8280 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ -fn main() { - println!("Hello, world!"); +mod db; +mod model; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let _pool = db::connect().await?; + println!("database ready at {}", db::db_path()?.display()); + Ok(()) }