use anyhow::{Result, bail};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
pub const DB_DIR: &str = ".kanban";
pub const DB_FILE: &str = "board.db";
static PATH_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
pub fn set_path_override(path: PathBuf) {
let _ = PATH_OVERRIDE.set(path);
}
pub const SCHEMA_VERSION: i32 = 1;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
priority TEXT NOT NULL CHECK (priority IN ('low','medium','high','urgent')),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('backlog','todo','in_progress','review','done')),
executor INTEGER REFERENCES agents(id),
tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags)),
tests TEXT NOT NULL CHECK (json_valid(tests) AND json_array_length(tests) > 0),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
";
pub fn discover() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(DB_DIR).join(DB_FILE);
if candidate.exists() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn open_at(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?;
conn.execute_batch(
"PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;",
)?;
Ok(conn)
}
pub fn open_existing() -> Result<Connection> {
let path = match PATH_OVERRIDE.get() {
Some(path) => {
if !path.exists() {
bail!(
"database file {} not found; run `agent-kanban --db {} init` first",
path.display(),
path.display()
);
}
path.clone()
}
None => discover().ok_or_else(|| {
anyhow::anyhow!("not a kanban project (no .kanban/ found); run `agent-kanban init`")
})?,
};
let conn = open_at(&path)?;
check_schema_version(&conn)?;
Ok(conn)
}
fn check_schema_version(conn: &Connection) -> Result<()> {
let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if version > SCHEMA_VERSION {
bail!(
"this project's schema version ({version}) is newer than this build of \
agent-kanban supports ({SCHEMA_VERSION}); upgrade agent-kanban"
);
}
Ok(())
}
pub fn init() -> Result<()> {
const MAX_ATTEMPTS: u32 = 10;
let path = if let Some(path) = PATH_OVERRIDE.get() {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
path.clone()
} else {
let dir = std::env::current_dir()?.join(DB_DIR);
std::fs::create_dir_all(&dir)?;
dir.join(DB_FILE)
};
let mut attempt = 0;
loop {
match try_init(&path) {
Ok(()) => return Ok(()),
Err(e)
if attempt + 1 < MAX_ATTEMPTS && e.to_string().contains("database is locked") =>
{
attempt += 1;
std::thread::sleep(std::time::Duration::from_millis(u64::from(20 * attempt)));
}
Err(e) => return Err(e),
}
}
}
fn try_init(path: &Path) -> Result<()> {
let conn = open_at(path)?;
conn.execute_batch(SCHEMA)?;
conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_at_current_is_accepted() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))
.unwrap();
check_schema_version(&conn).unwrap();
}
#[test]
fn schema_version_zero_legacy_is_accepted() {
let conn = Connection::open_in_memory().unwrap();
check_schema_version(&conn).unwrap();
}
#[test]
fn schema_version_newer_than_supported_is_rejected() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
.unwrap();
let err = check_schema_version(&conn).unwrap_err();
assert!(err.to_string().contains("newer than this build"));
}
}