merge: initial db setup
This commit is contained in:
2
migrations/0001_initial.down.sql
Normal file
2
migrations/0001_initial.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS todo_tags;
|
||||
DROP TABLE IF EXISTS todos;
|
||||
14
migrations/0001_initial.up.sql
Normal file
14
migrations/0001_initial.up.sql
Normal 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
151
src/db.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/main.rs
10
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(())
|
||||
}
|
||||
|
||||
64
src/model.rs
Normal file
64
src/model.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user