From 507cc7434ea6820a792fe5adb3fd6e555e61226e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sat, 28 Feb 2026 12:23:06 +0100 Subject: [PATCH 1/2] feat: add initial models --- src/model.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/model.rs diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..0c2fa32 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,64 @@ +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum Priority { + Low, + Medium, + High, + Critical, +} + +impl Default for Priority { + fn default() -> Self { + Self::Medium + } +} + +impl std::fmt::Display for Priority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "med"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "CRIT"), + } + } +} + +impl std::str::FromStr for Priority { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" | "l" | "1" => Ok(Self::Low), + "medium" | "med" | "m" | "2" => Ok(Self::Medium), + "high" | "hi" | "h" | "3" => Ok(Self::High), + "critical" | "crit" | "c" | "4" => Ok(Self::Critical), + _ => anyhow::bail!("invalid priority: {s} (use low/medium/high/critical)"), + } + } +} + +#[derive(Debug, Clone)] +pub struct Todo { + pub id: i64, + pub text: String, + pub priority: Priority, + pub project: Option, + pub tags: Vec, + pub created_at: DateTime, + pub completed_at: Option>, +} + +impl Todo { + pub fn is_done(&self) -> bool { + self.completed_at.is_some() + } + + /// short project name (last path component) + pub fn project_short(&self) -> Option<&str> { + self.project + .as_deref() + .and_then(|p| p.rsplit('/').next()) + } +} From 00bd396bbeccad91ff3539c61bd32adc63dc9654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sat, 28 Feb 2026 12:23:24 +0100 Subject: [PATCH 2/2] feat: add initial db connection and layout --- migrations/0001_initial.down.sql | 2 + migrations/0001_initial.up.sql | 14 +++ src/db.rs | 151 +++++++++++++++++++++++++++++++ src/main.rs | 10 +- 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 migrations/0001_initial.down.sql create mode 100644 migrations/0001_initial.up.sql create mode 100644 src/db.rs 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(()) }