1use crate::{PawanError, Result};
10use rusqlite::{params, Connection};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct BeadId(pub String);
21
22impl BeadId {
23 pub fn generate(title: &str, created_at: &str) -> Self {
32 use std::collections::hash_map::DefaultHasher;
33 use std::hash::{Hash, Hasher};
34 let mut hasher = DefaultHasher::new();
35 title.hash(&mut hasher);
36 created_at.hash(&mut hasher);
37 let hash = hasher.finish();
38 Self(format!("{:08x}", hash & 0xFFFFFFFF))
39 }
40
41 pub fn display(&self) -> String {
46 format!("bd-{}", self.0)
47 }
48
49 pub fn parse(s: &str) -> Self {
59 Self(s.strip_prefix("bd-").unwrap_or(s).to_string())
60 }
61}
62
63impl std::fmt::Display for BeadId {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 write!(f, "bd-{}", self.0)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum BeadStatus {
78 Open,
79 InProgress,
80 Closed,
81}
82
83impl BeadStatus {
84 pub fn to_str(&self) -> &'static str {
89 match self {
90 Self::Open => "open",
91 Self::InProgress => "in_progress",
92 Self::Closed => "closed",
93 }
94 }
95
96 pub fn from_str(s: &str) -> Self {
104 match s {
105 "in_progress" => Self::InProgress,
106 "closed" => Self::Closed,
107 _ => Self::Open,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Bead {
126 pub id: BeadId,
127 pub title: String,
128 pub description: Option<String>,
129 pub status: BeadStatus,
130 pub priority: u8,
132 pub created_at: String,
133 pub updated_at: String,
134 pub closed_at: Option<String>,
135 pub closed_reason: Option<String>,
136}
137
138pub struct BeadStore {
153 conn: Connection,
154}
155
156impl BeadStore {
157 pub fn open() -> Result<Self> {
159 let path = Self::db_path()?;
160 if let Some(parent) = path.parent() {
161 std::fs::create_dir_all(parent)
162 .map_err(|e| PawanError::Config(format!("Create dir: {}", e)))?;
163 }
164 let conn = Connection::open(&path)
165 .map_err(|e| PawanError::Config(format!("Open DB: {}", e)))?;
166 let store = Self { conn };
167 store.init_schema()?;
168 Ok(store)
169 }
170
171 pub fn with_conn(conn: Connection) -> Result<Self> {
173 let store = Self { conn };
174 store.init_schema()?;
175 Ok(store)
176 }
177
178 fn db_path() -> Result<PathBuf> {
179 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
180 Ok(PathBuf::from(home).join(".pawan").join("beads.db"))
181 }
182
183 fn init_schema(&self) -> Result<()> {
184 self.conn
185 .execute_batch(
186 "CREATE TABLE IF NOT EXISTS beads (
187 id TEXT PRIMARY KEY,
188 title TEXT NOT NULL,
189 description TEXT,
190 status TEXT NOT NULL DEFAULT 'open',
191 priority INTEGER NOT NULL DEFAULT 2,
192 created_at TEXT NOT NULL,
193 updated_at TEXT NOT NULL,
194 closed_at TEXT,
195 closed_reason TEXT
196 );
197 CREATE TABLE IF NOT EXISTS deps (
198 bead_id TEXT NOT NULL,
199 depends_on TEXT NOT NULL,
200 PRIMARY KEY (bead_id, depends_on),
201 FOREIGN KEY (bead_id) REFERENCES beads(id),
202 FOREIGN KEY (depends_on) REFERENCES beads(id)
203 );
204 CREATE TABLE IF NOT EXISTS archives (
205 id INTEGER PRIMARY KEY AUTOINCREMENT,
206 summary TEXT NOT NULL,
207 bead_count INTEGER NOT NULL,
208 archived_at TEXT NOT NULL
209 );",
210 )
211 .map_err(|e| PawanError::Config(format!("Schema: {}", e)))?;
212 Ok(())
213 }
214
215 pub fn create(&self, title: &str, description: Option<&str>, priority: u8) -> Result<Bead> {
217 let now = chrono::Utc::now().to_rfc3339();
218 let id = BeadId::generate(title, &now);
219
220 self.conn
221 .execute(
222 "INSERT INTO beads (id, title, description, status, priority, created_at, updated_at)
223 VALUES (?1, ?2, ?3, 'open', ?4, ?5, ?6)",
224 params![id.0, title, description, priority, now, now],
225 )
226 .map_err(|e| PawanError::Config(format!("Insert: {}", e)))?;
227
228 Ok(Bead {
229 id,
230 title: title.into(),
231 description: description.map(String::from),
232 status: BeadStatus::Open,
233 priority,
234 created_at: now.clone(),
235 updated_at: now,
236 closed_at: None,
237 closed_reason: None,
238 })
239 }
240
241 pub fn get(&self, id: &BeadId) -> Result<Bead> {
243 self.conn
244 .query_row(
245 "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason
246 FROM beads WHERE id = ?1",
247 params![id.0],
248 |row| {
249 Ok(Bead {
250 id: BeadId(row.get::<_, String>(0)?),
251 title: row.get(1)?,
252 description: row.get(2)?,
253 status: BeadStatus::from_str(&row.get::<_, String>(3)?),
254 priority: row.get(4)?,
255 created_at: row.get(5)?,
256 updated_at: row.get(6)?,
257 closed_at: row.get(7)?,
258 closed_reason: row.get(8)?,
259 })
260 },
261 )
262 .map_err(|e| PawanError::NotFound(format!("Bead {}: {}", id, e)))
263 }
264
265 pub fn update(
267 &self,
268 id: &BeadId,
269 title: Option<&str>,
270 status: Option<BeadStatus>,
271 priority: Option<u8>,
272 ) -> Result<()> {
273 let now = chrono::Utc::now().to_rfc3339();
274
275 if let Some(t) = title {
276 self.conn
277 .execute(
278 "UPDATE beads SET title = ?1, updated_at = ?2 WHERE id = ?3",
279 params![t, now, id.0],
280 )
281 .map_err(|e| PawanError::Config(format!("Update title: {}", e)))?;
282 }
283 if let Some(s) = status {
284 self.conn
285 .execute(
286 "UPDATE beads SET status = ?1, updated_at = ?2 WHERE id = ?3",
287 params![s.to_str(), now, id.0],
288 )
289 .map_err(|e| PawanError::Config(format!("Update status: {}", e)))?;
290 }
291 if let Some(p) = priority {
292 self.conn
293 .execute(
294 "UPDATE beads SET priority = ?1, updated_at = ?2 WHERE id = ?3",
295 params![p, now, id.0],
296 )
297 .map_err(|e| PawanError::Config(format!("Update priority: {}", e)))?;
298 }
299 Ok(())
300 }
301
302 pub fn close(&self, id: &BeadId, reason: Option<&str>) -> Result<()> {
304 let now = chrono::Utc::now().to_rfc3339();
305 self.conn
306 .execute(
307 "UPDATE beads SET status = 'closed', closed_at = ?1, closed_reason = ?2, updated_at = ?3 WHERE id = ?4",
308 params![now, reason, now, id.0],
309 )
310 .map_err(|e| PawanError::Config(format!("Close: {}", e)))?;
311 Ok(())
312 }
313
314 pub fn delete(&self, id: &BeadId) -> Result<()> {
316 self.conn
317 .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id.0])
318 .map_err(|e| PawanError::Config(format!("Delete deps: {}", e)))?;
319 self.conn
320 .execute("DELETE FROM beads WHERE id = ?1", params![id.0])
321 .map_err(|e| PawanError::Config(format!("Delete: {}", e)))?;
322 Ok(())
323 }
324
325 pub fn list(
327 &self,
328 status: Option<&str>,
329 max_priority: Option<u8>,
330 ) -> Result<Vec<Bead>> {
331 let mut sql = "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason FROM beads WHERE 1=1".to_string();
332 let mut bind_vals: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
333
334 if let Some(s) = status {
335 sql.push_str(&format!(" AND status = ?{}", bind_vals.len() + 1));
336 bind_vals.push(Box::new(s.to_string()));
337 }
338 if let Some(p) = max_priority {
339 sql.push_str(&format!(" AND priority <= ?{}", bind_vals.len() + 1));
340 bind_vals.push(Box::new(p));
341 }
342 sql.push_str(" ORDER BY priority ASC, updated_at DESC");
343
344 let params_refs: Vec<&dyn rusqlite::types::ToSql> = bind_vals.iter().map(|b| b.as_ref()).collect();
345
346 let mut stmt = self.conn.prepare(&sql)
347 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
348
349 let beads = stmt
350 .query_map(params_refs.as_slice(), |row| {
351 Ok(Bead {
352 id: BeadId(row.get::<_, String>(0)?),
353 title: row.get(1)?,
354 description: row.get(2)?,
355 status: BeadStatus::from_str(&row.get::<_, String>(3)?),
356 priority: row.get(4)?,
357 created_at: row.get(5)?,
358 updated_at: row.get(6)?,
359 closed_at: row.get(7)?,
360 closed_reason: row.get(8)?,
361 })
362 })
363 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
364 .filter_map(|r| r.ok())
365 .collect();
366
367 Ok(beads)
368 }
369
370 pub fn dep_add(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
372 self.conn
373 .execute(
374 "INSERT OR IGNORE INTO deps (bead_id, depends_on) VALUES (?1, ?2)",
375 params![bead_id.0, depends_on.0],
376 )
377 .map_err(|e| PawanError::Config(format!("Dep add: {}", e)))?;
378 Ok(())
379 }
380
381 pub fn dep_remove(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
383 self.conn
384 .execute(
385 "DELETE FROM deps WHERE bead_id = ?1 AND depends_on = ?2",
386 params![bead_id.0, depends_on.0],
387 )
388 .map_err(|e| PawanError::Config(format!("Dep rm: {}", e)))?;
389 Ok(())
390 }
391
392 pub fn deps(&self, bead_id: &BeadId) -> Result<Vec<BeadId>> {
394 let mut stmt = self.conn
395 .prepare("SELECT depends_on FROM deps WHERE bead_id = ?1")
396 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
397
398 let ids = stmt
399 .query_map(params![bead_id.0], |row| {
400 Ok(BeadId(row.get::<_, String>(0)?))
401 })
402 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
403 .filter_map(|r| r.ok())
404 .collect();
405
406 Ok(ids)
407 }
408
409 pub fn ready(&self) -> Result<Vec<Bead>> {
411 let all_open = self.list(Some("open"), None)?;
412 let mut ready = Vec::new();
413
414 for bead in all_open {
415 let deps = self.deps(&bead.id)?;
416 let all_closed = deps.iter().all(|dep_id| {
417 self.get(dep_id)
418 .map(|b| b.status == BeadStatus::Closed)
419 .unwrap_or(true) });
421 if all_closed {
422 ready.push(bead);
423 }
424 }
425
426 Ok(ready)
427 }
428
429 pub fn memory_decay(&self, max_age_days: u64) -> Result<usize> {
431 let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days as i64);
432 let cutoff_str = cutoff.to_rfc3339();
433
434 let mut stmt = self.conn
436 .prepare(
437 "SELECT id, title, closed_reason FROM beads
438 WHERE status = 'closed' AND closed_at < ?1
439 ORDER BY closed_at ASC",
440 )
441 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
442
443 let old_beads: Vec<(String, String, Option<String>)> = stmt
444 .query_map(params![cutoff_str], |row| {
445 Ok((
446 row.get::<_, String>(0)?,
447 row.get::<_, String>(1)?,
448 row.get::<_, Option<String>>(2)?,
449 ))
450 })
451 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
452 .filter_map(|r| r.ok())
453 .collect();
454
455 if old_beads.is_empty() {
456 return Ok(0);
457 }
458
459 let count = old_beads.len();
460
461 let summary_lines: Vec<String> = old_beads
463 .iter()
464 .map(|(id, title, reason)| {
465 let r = reason.as_deref().unwrap_or("done");
466 format!("- bd-{}: {} ({})", id, title, r)
467 })
468 .collect();
469 let summary = format!(
470 "Archived {} beads (before {}):\n{}",
471 count,
472 cutoff_str,
473 summary_lines.join("\n")
474 );
475
476 let now = chrono::Utc::now().to_rfc3339();
477 self.conn
478 .execute(
479 "INSERT INTO archives (summary, bead_count, archived_at) VALUES (?1, ?2, ?3)",
480 params![summary, count, now],
481 )
482 .map_err(|e| PawanError::Config(format!("Archive: {}", e)))?;
483
484 for (id, _, _) in &old_beads {
486 self.conn
487 .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id])
488 .ok();
489 self.conn
490 .execute("DELETE FROM beads WHERE id = ?1", params![id])
491 .ok();
492 }
493
494 Ok(count)
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn test_store() -> BeadStore {
503 let conn = Connection::open_in_memory().unwrap();
504 BeadStore::with_conn(conn).unwrap()
505 }
506
507 #[test]
508 fn create_and_get() {
509 let store = test_store();
510 let bead = store.create("Fix bug", Some("It's broken"), 1).unwrap();
511 assert!(bead.id.0.len() == 8);
512 assert_eq!(bead.title, "Fix bug");
513 assert_eq!(bead.priority, 1);
514
515 let loaded = store.get(&bead.id).unwrap();
516 assert_eq!(loaded.title, "Fix bug");
517 }
518
519 #[test]
520 fn list_filters() {
521 let store = test_store();
522 store.create("A", None, 0).unwrap();
523 store.create("B", None, 2).unwrap();
524 let c = store.create("C", None, 4).unwrap();
525 store.close(&c.id, Some("done")).unwrap();
526
527 let all = store.list(None, None).unwrap();
528 assert_eq!(all.len(), 3);
529
530 let open = store.list(Some("open"), None).unwrap();
531 assert_eq!(open.len(), 2);
532
533 let critical = store.list(None, Some(1)).unwrap();
534 assert_eq!(critical.len(), 1);
535 assert_eq!(critical[0].title, "A");
536 }
537
538 #[test]
539 fn deps_and_ready() {
540 let store = test_store();
541 let a = store.create("Task A", None, 1).unwrap();
542 let b = store.create("Task B", None, 1).unwrap();
543 let c = store.create("Task C", None, 1).unwrap();
544
545 store.dep_add(&c.id, &a.id).unwrap();
547 store.dep_add(&c.id, &b.id).unwrap();
548
549 let ready = store.ready().unwrap();
551 assert_eq!(ready.len(), 2);
552 let ready_ids: Vec<&str> = ready.iter().map(|b| b.id.0.as_str()).collect();
553 assert!(!ready_ids.contains(&c.id.0.as_str()));
554
555 store.close(&a.id, None).unwrap();
557 let ready = store.ready().unwrap();
558 assert_eq!(ready.len(), 1); assert_eq!(ready[0].id, b.id);
560
561 store.close(&b.id, None).unwrap();
563 let ready = store.ready().unwrap();
564 assert_eq!(ready.len(), 1);
565 assert_eq!(ready[0].id, c.id);
566 }
567
568 #[test]
569 fn close_and_delete() {
570 let store = test_store();
571 let bead = store.create("Temp", None, 3).unwrap();
572
573 store.close(&bead.id, Some("no longer needed")).unwrap();
574 let loaded = store.get(&bead.id).unwrap();
575 assert_eq!(loaded.status, BeadStatus::Closed);
576 assert_eq!(loaded.closed_reason.as_deref(), Some("no longer needed"));
577
578 store.delete(&bead.id).unwrap();
579 assert!(store.get(&bead.id).is_err());
580 }
581
582 #[test]
583 fn memory_decay_archives() {
584 let store = test_store();
585
586 let bead = store.create("Old task", None, 2).unwrap();
588 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
589 store.conn
590 .execute(
591 "UPDATE beads SET status = 'closed', closed_at = ?1 WHERE id = ?2",
592 params![old_time, bead.id.0],
593 )
594 .unwrap();
595
596 let recent = store.create("Recent task", None, 2).unwrap();
598 store.close(&recent.id, Some("just done")).unwrap();
599
600 let count = store.memory_decay(30).unwrap();
602 assert_eq!(count, 1);
603
604 assert!(store.get(&bead.id).is_err());
606 assert!(store.get(&recent.id).is_ok());
608
609 let summary: String = store.conn
611 .query_row("SELECT summary FROM archives ORDER BY id DESC LIMIT 1", [], |r| r.get(0))
612 .unwrap();
613 assert!(summary.contains("Old task"));
614 }
615}