merge: initial db setup

This commit is contained in:
2026-02-28 12:23:38 +01:00
5 changed files with 239 additions and 2 deletions

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS todo_tags;
DROP TABLE IF EXISTS todos;

View File

@@ -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)
);

151
src/db.rs Normal file
View File

@@ -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<PathBuf> {
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<Pool> {
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<String> {
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<Todo> {
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<Todo> {
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<String>,
created_at: chrono::DateTime<chrono::Utc>,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl TodoRow {
fn into_todo(self, tags: Vec<String>) -> 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,
}
}
}

View File

@@ -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(())
}

64
src/model.rs Normal file
View File

@@ -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<Self, Self::Err> {
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<String>,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
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())
}
}