use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
use crate::sync_util::LockExt;
pub fn normalize_status(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"open" | "todo" | "backlog" => Some("open"),
"in_progress" | "in-progress" | "started" | "doing" | "wip" => Some("in_progress"),
"blocked" | "block" => Some("blocked"),
"done" | "closed" | "complete" | "completed" | "finished" => Some("done"),
_ => None,
}
}
pub fn normalize_priority(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().as_str() {
"high" | "p0" | "p1" | "urgent" => Some("high"),
"normal" | "medium" | "med" | "p2" | "" => Some("normal"),
"low" | "p3" | "p4" | "minor" => Some("low"),
_ => None,
}
}
pub fn parse_issue_id(raw: &str) -> Option<i64> {
let s = raw.trim();
let s = s.strip_prefix("issue").map(str::trim).unwrap_or(s);
let s = s.strip_prefix('#').unwrap_or(s);
let s = s.strip_prefix("iss-").unwrap_or(s);
let s = s.strip_prefix("drg-").unwrap_or(s);
s.trim().parse::<i64>().ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub id: i64,
pub title: String,
pub body: String,
pub status: String,
pub priority: String,
pub session_id: Option<String>,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
}
impl Issue {
pub fn one_line(&self) -> String {
format!(
"#{} [{}] ({}) {}",
self.id, self.status, self.priority, self.title
)
}
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
fn row_to_issue(row: &rusqlite::Row<'_>) -> rusqlite::Result<Issue> {
Ok(Issue {
id: row.get(0)?,
title: row.get(1)?,
body: row.get(2)?,
status: row.get(3)?,
priority: row.get(4)?,
session_id: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
closed_at: row.get(8)?,
})
}
const COLS: &str =
"id, title, body, status, priority, session_id, created_at, updated_at, closed_at";
pub struct IssueStore {
conn: Mutex<Connection>,
}
impl IssueStore {
pub fn open(paths: &super::dirge_paths::ProjectPaths) -> Result<Self, String> {
Self::open_at(&paths.session_db_path())
}
pub fn open_at(path: &Path) -> Result<Self, String> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
)
.map_err(|e| format!("open issue db at {}: {e}", path.display()))?;
let _ = conn.busy_timeout(Duration::from_secs(5));
let _ = conn.pragma_update(None, "journal_mode", "WAL");
let store = Self {
conn: Mutex::new(conn),
};
store.ensure_schema()?;
Ok(store)
}
fn ensure_schema(&self) -> Result<(), String> {
let conn = self.conn.lock_ignore_poison();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority TEXT NOT NULL DEFAULT 'normal',
session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);",
)
.map_err(|e| format!("ensure issues schema: {e}"))
}
pub fn create(
&self,
title: &str,
body: &str,
priority: Option<&str>,
session_id: Option<&str>,
) -> Result<i64, String> {
let title = title.trim();
if title.is_empty() {
return Err("issue title must not be empty".to_string());
}
let priority = priority.and_then(normalize_priority).unwrap_or("normal");
let conn = self.conn.lock_ignore_poison();
let now = now();
conn.execute(
"INSERT INTO issues (title, body, status, priority, session_id, created_at, updated_at)
VALUES (?1, ?2, 'open', ?3, ?4, ?5, ?5)",
params![title, body.trim(), priority, session_id, now],
)
.map_err(|e| format!("create issue: {e}"))?;
Ok(conn.last_insert_rowid())
}
pub fn get(&self, id: i64) -> Result<Option<Issue>, String> {
let conn = self.conn.lock_ignore_poison();
conn.query_row(
&format!("SELECT {COLS} FROM issues WHERE id = ?1"),
params![id],
row_to_issue,
)
.optional()
.map_err(|e| format!("get issue: {e}"))
}
pub fn set_status(&self, id: i64, status: &str) -> Result<bool, String> {
let status = normalize_status(status).ok_or_else(|| {
format!("unknown status '{status}' (use open|in_progress|blocked|done)")
})?;
let conn = self.conn.lock_ignore_poison();
let now = now();
let n = if status == "done" {
conn.execute(
"UPDATE issues SET status = ?2, updated_at = ?3, closed_at = ?3 WHERE id = ?1",
params![id, status, now],
)
} else {
conn.execute(
"UPDATE issues SET status = ?2, updated_at = ?3, closed_at = NULL WHERE id = ?1",
params![id, status, now],
)
}
.map_err(|e| format!("set status: {e}"))?;
Ok(n > 0)
}
pub fn set_priority(&self, id: i64, priority: &str) -> Result<bool, String> {
let priority = normalize_priority(priority)
.ok_or_else(|| format!("unknown priority '{priority}' (use high|normal|low)"))?;
let conn = self.conn.lock_ignore_poison();
let n = conn
.execute(
"UPDATE issues SET priority = ?2, updated_at = ?3 WHERE id = ?1",
params![id, priority, now()],
)
.map_err(|e| format!("set priority: {e}"))?;
Ok(n > 0)
}
pub fn list_by_status(&self, status: &str) -> Result<Vec<Issue>, String> {
let status =
normalize_status(status).ok_or_else(|| format!("unknown status '{status}'"))?;
let conn = self.conn.lock_ignore_poison();
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM issues WHERE status = ?1 ORDER BY updated_at DESC"
))
.map_err(|e| format!("list: {e}"))?;
let rows = stmt
.query_map(params![status], row_to_issue)
.map_err(|e| format!("list: {e}"))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("list: {e}"))
}
pub fn board(&self, limit: Option<usize>) -> Result<Vec<Issue>, String> {
let conn = self.conn.lock_ignore_poison();
let sql = format!(
"SELECT {COLS} FROM issues
WHERE status IN ('open','in_progress','blocked')
ORDER BY
CASE status WHEN 'in_progress' THEN 0 WHEN 'blocked' THEN 1 ELSE 2 END,
CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
updated_at DESC
{}",
match limit {
Some(n) => format!("LIMIT {n}"),
None => String::new(),
}
);
let mut stmt = conn.prepare(&sql).map_err(|e| format!("board: {e}"))?;
let rows = stmt
.query_map([], row_to_issue)
.map_err(|e| format!("board: {e}"))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("board: {e}"))
}
pub fn open_count(&self) -> Result<usize, String> {
let conn = self.conn.lock_ignore_poison();
conn.query_row(
"SELECT COUNT(*) FROM issues WHERE status != 'done'",
[],
|row| row.get::<_, i64>(0),
)
.map(|n| n as usize)
.map_err(|e| format!("open_count: {e}"))
}
pub fn board_reminder(&self, top_n: usize) -> Result<Option<String>, String> {
let issues = self.board(Some(top_n))?;
if issues.is_empty() {
return Ok(None);
}
let total = self.open_count()?;
let mut s = String::from(
"<system-reminder>\nIssue board (your persistent kanban — surfaced automatically; you did not ask for it). \
As you work: `issue` tool with action=start when you begin one, action=close when done, action=create for newly-discovered work.\n",
);
for i in &issues {
s.push_str(&format!("- {}\n", i.one_line()));
}
let shown = issues.len();
if total > shown {
s.push_str(&format!(
"… and {} more open issue(s) not shown. Use the `issue` tool (action=list) or /issues to see all.\n",
total - shown
));
}
s.push_str("</system-reminder>");
Ok(Some(s))
}
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Issue>, String> {
let conn = self.conn.lock_ignore_poison();
let like = format!("%{}%", query.trim());
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM issues
WHERE title LIKE ?1 COLLATE NOCASE OR body LIKE ?1 COLLATE NOCASE
ORDER BY updated_at DESC LIMIT ?2"
))
.map_err(|e| format!("search: {e}"))?;
let rows = stmt
.query_map(params![like, limit as i64], row_to_issue)
.map_err(|e| format!("search: {e}"))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("search: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> IssueStore {
let dir = std::env::temp_dir().join(format!(
"dirge-issue-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
IssueStore::open_at(&dir.join("state.db")).unwrap()
}
#[test]
fn create_and_get_roundtrip() {
let s = store();
let id = s
.create("Build auth", "use PKCE", Some("high"), Some("sess-1"))
.unwrap();
let issue = s.get(id).unwrap().expect("issue exists");
assert_eq!(issue.title, "Build auth");
assert_eq!(issue.body, "use PKCE");
assert_eq!(issue.status, "open");
assert_eq!(issue.priority, "high");
assert_eq!(issue.session_id.as_deref(), Some("sess-1"));
assert!(issue.closed_at.is_none());
}
#[test]
fn empty_title_rejected() {
let s = store();
assert!(s.create(" ", "", None, None).is_err());
}
#[test]
fn unknown_priority_falls_back_to_normal() {
let s = store();
let id = s.create("x", "", Some("supercritical"), None).unwrap();
assert_eq!(s.get(id).unwrap().unwrap().priority, "normal");
}
#[test]
fn set_status_done_stamps_closed_at_and_clears_on_reopen() {
let s = store();
let id = s.create("x", "", None, None).unwrap();
assert!(s.set_status(id, "done").unwrap());
let done = s.get(id).unwrap().unwrap();
assert_eq!(done.status, "done");
assert!(done.closed_at.is_some());
assert!(s.set_status(id, "in_progress").unwrap());
let reopened = s.get(id).unwrap().unwrap();
assert_eq!(reopened.status, "in_progress");
assert!(reopened.closed_at.is_none());
}
#[test]
fn set_status_aliases_normalize() {
let s = store();
let id = s.create("x", "", None, None).unwrap();
assert!(s.set_status(id, "WIP").unwrap());
assert_eq!(s.get(id).unwrap().unwrap().status, "in_progress");
}
#[test]
fn set_status_unknown_rejected_and_missing_id_is_false() {
let s = store();
let id = s.create("x", "", None, None).unwrap();
assert!(s.set_status(id, "nonsense").is_err());
assert!(!s.set_status(999, "done").unwrap());
}
#[test]
fn board_orders_in_progress_then_priority_and_excludes_done() {
let s = store();
let _low_open = s.create("low open", "", Some("low"), None).unwrap();
let high_open = s.create("high open", "", Some("high"), None).unwrap();
let wip = s.create("wip", "", Some("low"), None).unwrap();
s.set_status(wip, "in_progress").unwrap();
let done = s.create("done", "", Some("high"), None).unwrap();
s.set_status(done, "done").unwrap();
let board = s.board(None).unwrap();
let ids: Vec<i64> = board.iter().map(|i| i.id).collect();
assert_eq!(ids, vec![wip, high_open, _low_open]);
assert!(!ids.contains(&done));
}
#[test]
fn board_limit_caps_rows() {
let s = store();
for i in 0..5 {
s.create(&format!("issue {i}"), "", None, None).unwrap();
}
assert_eq!(s.board(Some(2)).unwrap().len(), 2);
assert_eq!(s.open_count().unwrap(), 5);
}
#[test]
fn search_matches_title_and_body_case_insensitive() {
let s = store();
s.create("Refactor auth", "", None, None).unwrap();
s.create("Other", "touches AUTH layer", None, None).unwrap();
s.create("Unrelated", "nope", None, None).unwrap();
let hits = s.search("auth", 10).unwrap();
assert_eq!(hits.len(), 2);
}
#[test]
fn board_reminder_none_when_empty_and_hints_overflow() {
let s = store();
assert!(
s.board_reminder(5).unwrap().is_none(),
"empty board → no reminder"
);
for i in 0..4 {
s.create(&format!("issue {i}"), "", None, None).unwrap();
}
let block = s.board_reminder(2).unwrap().expect("non-empty board");
assert!(block.starts_with("<system-reminder>"));
assert!(block.trim_end().ends_with("</system-reminder>"));
assert!(block.contains("2 more open issue"), "{block}");
}
#[test]
fn parse_issue_id_accepts_intuitive_forms() {
assert_eq!(parse_issue_id("7"), Some(7));
assert_eq!(parse_issue_id("#7"), Some(7));
assert_eq!(parse_issue_id("iss-7"), Some(7));
assert_eq!(parse_issue_id("issue 7"), Some(7));
assert_eq!(parse_issue_id("nope"), None);
}
}