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}
97
98impl std::str::FromStr for BeadStatus {
99 type Err = std::convert::Infallible;
100
101 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
102 Ok(match s {
103 "in_progress" => Self::InProgress,
104 "closed" => Self::Closed,
105 _ => Self::Open,
106 })
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Bead {
124 pub id: BeadId,
125 pub title: String,
126 pub description: Option<String>,
127 pub status: BeadStatus,
128 pub priority: u8,
130 pub created_at: String,
131 pub updated_at: String,
132 pub closed_at: Option<String>,
133 pub closed_reason: Option<String>,
134}
135
136pub struct BeadStore {
151 conn: Connection,
152}
153
154impl BeadStore {
155 pub fn open() -> Result<Self> {
157 let path = Self::db_path()?;
158 if let Some(parent) = path.parent() {
159 std::fs::create_dir_all(parent)
160 .map_err(|e| PawanError::Config(format!("Create dir: {}", e)))?;
161 }
162 let conn = Connection::open(&path)
163 .map_err(|e| PawanError::Config(format!("Open DB: {}", e)))?;
164 let store = Self { conn };
165 store.init_schema()?;
166 Ok(store)
167 }
168
169 pub fn with_conn(conn: Connection) -> Result<Self> {
171 let store = Self { conn };
172 store.init_schema()?;
173 Ok(store)
174 }
175
176 fn db_path() -> Result<PathBuf> {
177 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
178 Ok(PathBuf::from(home).join(".pawan").join("beads.db"))
179 }
180
181 fn init_schema(&self) -> Result<()> {
182 self.conn
183 .execute_batch(
184 "CREATE TABLE IF NOT EXISTS beads (
185 id TEXT PRIMARY KEY,
186 title TEXT NOT NULL,
187 description TEXT,
188 status TEXT NOT NULL DEFAULT 'open',
189 priority INTEGER NOT NULL DEFAULT 2,
190 created_at TEXT NOT NULL,
191 updated_at TEXT NOT NULL,
192 closed_at TEXT,
193 closed_reason TEXT
194 );
195 CREATE TABLE IF NOT EXISTS deps (
196 bead_id TEXT NOT NULL,
197 depends_on TEXT NOT NULL,
198 PRIMARY KEY (bead_id, depends_on),
199 FOREIGN KEY (bead_id) REFERENCES beads(id),
200 FOREIGN KEY (depends_on) REFERENCES beads(id)
201 );
202 CREATE TABLE IF NOT EXISTS archives (
203 id INTEGER PRIMARY KEY AUTOINCREMENT,
204 summary TEXT NOT NULL,
205 bead_count INTEGER NOT NULL,
206 archived_at TEXT NOT NULL
207 );",
208 )
209 .map_err(|e| PawanError::Config(format!("Schema: {}", e)))?;
210 Ok(())
211 }
212
213 pub fn create(&self, title: &str, description: Option<&str>, priority: u8) -> Result<Bead> {
215 let now = chrono::Utc::now().to_rfc3339();
216 let id = BeadId::generate(title, &now);
217
218 self.conn
219 .execute(
220 "INSERT INTO beads (id, title, description, status, priority, created_at, updated_at)
221 VALUES (?1, ?2, ?3, 'open', ?4, ?5, ?6)",
222 params![id.0, title, description, priority, now, now],
223 )
224 .map_err(|e| PawanError::Config(format!("Insert: {}", e)))?;
225
226 Ok(Bead {
227 id,
228 title: title.into(),
229 description: description.map(String::from),
230 status: BeadStatus::Open,
231 priority,
232 created_at: now.clone(),
233 updated_at: now,
234 closed_at: None,
235 closed_reason: None,
236 })
237 }
238
239 pub fn get(&self, id: &BeadId) -> Result<Bead> {
241 self.conn
242 .query_row(
243 "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason
244 FROM beads WHERE id = ?1",
245 params![id.0],
246 |row| {
247 Ok(Bead {
248 id: BeadId(row.get::<_, String>(0)?),
249 title: row.get(1)?,
250 description: row.get(2)?,
251 status: row.get::<_, String>(3)?.parse().unwrap_or(BeadStatus::Open),
252 priority: row.get(4)?,
253 created_at: row.get(5)?,
254 updated_at: row.get(6)?,
255 closed_at: row.get(7)?,
256 closed_reason: row.get(8)?,
257 })
258 },
259 )
260 .map_err(|e| PawanError::NotFound(format!("Bead {}: {}", id, e)))
261 }
262
263 pub fn update(
265 &self,
266 id: &BeadId,
267 title: Option<&str>,
268 status: Option<BeadStatus>,
269 priority: Option<u8>,
270 ) -> Result<()> {
271 let now = chrono::Utc::now().to_rfc3339();
272
273 if let Some(t) = title {
274 self.conn
275 .execute(
276 "UPDATE beads SET title = ?1, updated_at = ?2 WHERE id = ?3",
277 params![t, now, id.0],
278 )
279 .map_err(|e| PawanError::Config(format!("Update title: {}", e)))?;
280 }
281 if let Some(s) = status {
282 self.conn
283 .execute(
284 "UPDATE beads SET status = ?1, updated_at = ?2 WHERE id = ?3",
285 params![s.to_str(), now, id.0],
286 )
287 .map_err(|e| PawanError::Config(format!("Update status: {}", e)))?;
288 }
289 if let Some(p) = priority {
290 self.conn
291 .execute(
292 "UPDATE beads SET priority = ?1, updated_at = ?2 WHERE id = ?3",
293 params![p, now, id.0],
294 )
295 .map_err(|e| PawanError::Config(format!("Update priority: {}", e)))?;
296 }
297 Ok(())
298 }
299
300 pub fn close(&self, id: &BeadId, reason: Option<&str>) -> Result<()> {
302 let now = chrono::Utc::now().to_rfc3339();
303 self.conn
304 .execute(
305 "UPDATE beads SET status = 'closed', closed_at = ?1, closed_reason = ?2, updated_at = ?3 WHERE id = ?4",
306 params![now, reason, now, id.0],
307 )
308 .map_err(|e| PawanError::Config(format!("Close: {}", e)))?;
309 Ok(())
310 }
311
312 pub fn delete(&self, id: &BeadId) -> Result<()> {
314 self.conn
315 .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id.0])
316 .map_err(|e| PawanError::Config(format!("Delete deps: {}", e)))?;
317 self.conn
318 .execute("DELETE FROM beads WHERE id = ?1", params![id.0])
319 .map_err(|e| PawanError::Config(format!("Delete: {}", e)))?;
320 Ok(())
321 }
322
323 pub fn list(
325 &self,
326 status: Option<&str>,
327 max_priority: Option<u8>,
328 ) -> Result<Vec<Bead>> {
329 let mut sql = "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason FROM beads WHERE 1=1".to_string();
330 let mut bind_vals: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
331
332 if let Some(s) = status {
333 sql.push_str(&format!(" AND status = ?{}", bind_vals.len() + 1));
334 bind_vals.push(Box::new(s.to_string()));
335 }
336 if let Some(p) = max_priority {
337 sql.push_str(&format!(" AND priority <= ?{}", bind_vals.len() + 1));
338 bind_vals.push(Box::new(p));
339 }
340 sql.push_str(" ORDER BY priority ASC, updated_at DESC");
341
342 let params_refs: Vec<&dyn rusqlite::types::ToSql> = bind_vals.iter().map(|b| b.as_ref()).collect();
343
344 let mut stmt = self.conn.prepare(&sql)
345 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
346
347 let beads = stmt
348 .query_map(params_refs.as_slice(), |row| {
349 Ok(Bead {
350 id: BeadId(row.get::<_, String>(0)?),
351 title: row.get(1)?,
352 description: row.get(2)?,
353 status: row.get::<_, String>(3)?.parse().unwrap_or(BeadStatus::Open),
354 priority: row.get(4)?,
355 created_at: row.get(5)?,
356 updated_at: row.get(6)?,
357 closed_at: row.get(7)?,
358 closed_reason: row.get(8)?,
359 })
360 })
361 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
362 .filter_map(|r| r.ok())
363 .collect();
364
365 Ok(beads)
366 }
367
368 pub fn dep_add(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
370 self.conn
371 .execute(
372 "INSERT OR IGNORE INTO deps (bead_id, depends_on) VALUES (?1, ?2)",
373 params![bead_id.0, depends_on.0],
374 )
375 .map_err(|e| PawanError::Config(format!("Dep add: {}", e)))?;
376 Ok(())
377 }
378
379 pub fn dep_remove(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
381 self.conn
382 .execute(
383 "DELETE FROM deps WHERE bead_id = ?1 AND depends_on = ?2",
384 params![bead_id.0, depends_on.0],
385 )
386 .map_err(|e| PawanError::Config(format!("Dep rm: {}", e)))?;
387 Ok(())
388 }
389
390 pub fn deps(&self, bead_id: &BeadId) -> Result<Vec<BeadId>> {
392 let mut stmt = self.conn
393 .prepare("SELECT depends_on FROM deps WHERE bead_id = ?1")
394 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
395
396 let ids = stmt
397 .query_map(params![bead_id.0], |row| {
398 Ok(BeadId(row.get::<_, String>(0)?))
399 })
400 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
401 .filter_map(|r| r.ok())
402 .collect();
403
404 Ok(ids)
405 }
406
407 pub fn ready(&self) -> Result<Vec<Bead>> {
409 let all_open = self.list(Some("open"), None)?;
410 let mut ready = Vec::new();
411
412 for bead in all_open {
413 let deps = self.deps(&bead.id)?;
414 let all_closed = deps.iter().all(|dep_id| {
415 self.get(dep_id)
416 .map(|b| b.status == BeadStatus::Closed)
417 .unwrap_or(true) });
419 if all_closed {
420 ready.push(bead);
421 }
422 }
423
424 Ok(ready)
425 }
426
427 pub fn memory_decay(&self, max_age_days: u64) -> Result<usize> {
429 let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days as i64);
430 let cutoff_str = cutoff.to_rfc3339();
431
432 let mut stmt = self.conn
434 .prepare(
435 "SELECT id, title, closed_reason FROM beads
436 WHERE status = 'closed' AND closed_at < ?1
437 ORDER BY closed_at ASC",
438 )
439 .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
440
441 let old_beads: Vec<(String, String, Option<String>)> = stmt
442 .query_map(params![cutoff_str], |row| {
443 Ok((
444 row.get::<_, String>(0)?,
445 row.get::<_, String>(1)?,
446 row.get::<_, Option<String>>(2)?,
447 ))
448 })
449 .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
450 .filter_map(|r| r.ok())
451 .collect();
452
453 if old_beads.is_empty() {
454 return Ok(0);
455 }
456
457 let count = old_beads.len();
458
459 let summary_lines: Vec<String> = old_beads
461 .iter()
462 .map(|(id, title, reason)| {
463 let r = reason.as_deref().unwrap_or("done");
464 format!("- bd-{}: {} ({})", id, title, r)
465 })
466 .collect();
467 let summary = format!(
468 "Archived {} beads (before {}):\n{}",
469 count,
470 cutoff_str,
471 summary_lines.join("\n")
472 );
473
474 let now = chrono::Utc::now().to_rfc3339();
475 self.conn
476 .execute(
477 "INSERT INTO archives (summary, bead_count, archived_at) VALUES (?1, ?2, ?3)",
478 params![summary, count, now],
479 )
480 .map_err(|e| PawanError::Config(format!("Archive: {}", e)))?;
481
482 for (id, _, _) in &old_beads {
484 self.conn
485 .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id])
486 .ok();
487 self.conn
488 .execute("DELETE FROM beads WHERE id = ?1", params![id])
489 .ok();
490 }
491
492 Ok(count)
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 fn test_store() -> BeadStore {
501 let conn = Connection::open_in_memory().unwrap();
502 BeadStore::with_conn(conn).unwrap()
503 }
504
505 #[test]
506 fn create_and_get() {
507 let store = test_store();
508 let bead = store.create("Fix bug", Some("It's broken"), 1).unwrap();
509 assert!(bead.id.0.len() == 8);
510 assert_eq!(bead.title, "Fix bug");
511 assert_eq!(bead.priority, 1);
512
513 let loaded = store.get(&bead.id).unwrap();
514 assert_eq!(loaded.title, "Fix bug");
515 }
516
517 #[test]
518 fn list_filters() {
519 let store = test_store();
520 store.create("A", None, 0).unwrap();
521 store.create("B", None, 2).unwrap();
522 let c = store.create("C", None, 4).unwrap();
523 store.close(&c.id, Some("done")).unwrap();
524
525 let all = store.list(None, None).unwrap();
526 assert_eq!(all.len(), 3);
527
528 let open = store.list(Some("open"), None).unwrap();
529 assert_eq!(open.len(), 2);
530
531 let critical = store.list(None, Some(1)).unwrap();
532 assert_eq!(critical.len(), 1);
533 assert_eq!(critical[0].title, "A");
534 }
535
536 #[test]
537 fn deps_and_ready() {
538 let store = test_store();
539 let a = store.create("Task A", None, 1).unwrap();
540 let b = store.create("Task B", None, 1).unwrap();
541 let c = store.create("Task C", None, 1).unwrap();
542
543 store.dep_add(&c.id, &a.id).unwrap();
545 store.dep_add(&c.id, &b.id).unwrap();
546
547 let ready = store.ready().unwrap();
549 assert_eq!(ready.len(), 2);
550 let ready_ids: Vec<&str> = ready.iter().map(|b| b.id.0.as_str()).collect();
551 assert!(!ready_ids.contains(&c.id.0.as_str()));
552
553 store.close(&a.id, None).unwrap();
555 let ready = store.ready().unwrap();
556 assert_eq!(ready.len(), 1); assert_eq!(ready[0].id, b.id);
558
559 store.close(&b.id, None).unwrap();
561 let ready = store.ready().unwrap();
562 assert_eq!(ready.len(), 1);
563 assert_eq!(ready[0].id, c.id);
564 }
565
566 #[test]
567 fn close_and_delete() {
568 let store = test_store();
569 let bead = store.create("Temp", None, 3).unwrap();
570
571 store.close(&bead.id, Some("no longer needed")).unwrap();
572 let loaded = store.get(&bead.id).unwrap();
573 assert_eq!(loaded.status, BeadStatus::Closed);
574 assert_eq!(loaded.closed_reason.as_deref(), Some("no longer needed"));
575
576 store.delete(&bead.id).unwrap();
577 assert!(store.get(&bead.id).is_err());
578 }
579
580 #[test]
581 fn memory_decay_archives() {
582 let store = test_store();
583
584 let bead = store.create("Old task", None, 2).unwrap();
586 let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
587 store.conn
588 .execute(
589 "UPDATE beads SET status = 'closed', closed_at = ?1 WHERE id = ?2",
590 params![old_time, bead.id.0],
591 )
592 .unwrap();
593
594 let recent = store.create("Recent task", None, 2).unwrap();
596 store.close(&recent.id, Some("just done")).unwrap();
597
598 let count = store.memory_decay(30).unwrap();
600 assert_eq!(count, 1);
601
602 assert!(store.get(&bead.id).is_err());
604 assert!(store.get(&recent.id).is_ok());
606
607 let summary: String = store.conn
609 .query_row("SELECT summary FROM archives ORDER BY id DESC LIMIT 1", [], |r| r.get(0))
610 .unwrap();
611 assert!(summary.contains("Old task"));
612 }
613
614 #[test]
615 fn bead_id_generate_is_deterministic() {
616 let a = BeadId::generate("fix auth", "2026-04-10T12:00:00Z");
619 let b = BeadId::generate("fix auth", "2026-04-10T12:00:00Z");
620 assert_eq!(a.0, b.0, "same inputs must produce same BeadId");
621 assert_eq!(a.0.len(), 8, "BeadId hash must always be 8 hex chars");
622 let c = BeadId::generate("fix auth", "2026-04-10T12:00:01Z");
624 assert_ne!(a.0, c.0, "different timestamps must produce different ids");
625 }
626
627 #[test]
628 fn bead_id_parse_strips_bd_prefix() {
629 let with_prefix = BeadId::parse("bd-deadbeef");
632 let without = BeadId::parse("deadbeef");
633 assert_eq!(with_prefix.0, "deadbeef");
634 assert_eq!(without.0, "deadbeef");
635 assert_eq!(with_prefix.display(), "bd-deadbeef");
637 assert_eq!(format!("{}", without), "bd-deadbeef");
638 }
639
640 #[test]
641 fn bead_status_parse_unknown_falls_back_to_open() {
642 use std::str::FromStr;
643 assert_eq!(BeadStatus::from_str("in_progress").unwrap(), BeadStatus::InProgress);
645 assert_eq!(BeadStatus::from_str("closed").unwrap(), BeadStatus::Closed);
646 assert_eq!(BeadStatus::from_str("open").unwrap(), BeadStatus::Open);
647 assert_eq!(BeadStatus::from_str("garbage").unwrap(), BeadStatus::Open);
649 assert_eq!(BeadStatus::from_str("").unwrap(), BeadStatus::Open);
650 for variant in [BeadStatus::Open, BeadStatus::InProgress, BeadStatus::Closed] {
652 let s = variant.to_str();
653 assert_eq!(BeadStatus::from_str(s).unwrap(), variant);
654 }
655 }
656
657 #[test]
658 fn update_each_field_independently() {
659 let store = test_store();
660 let bead = store.create("original", Some("desc"), 3).unwrap();
661
662 store.update(&bead.id, Some("renamed"), None, None).unwrap();
664 let loaded = store.get(&bead.id).unwrap();
665 assert_eq!(loaded.title, "renamed");
666 assert_eq!(loaded.status, BeadStatus::Open, "status must be unchanged");
667 assert_eq!(loaded.priority, 3, "priority must be unchanged");
668
669 store.update(&bead.id, None, Some(BeadStatus::InProgress), None).unwrap();
671 let loaded = store.get(&bead.id).unwrap();
672 assert_eq!(loaded.title, "renamed", "title must be unchanged");
673 assert_eq!(loaded.status, BeadStatus::InProgress);
674 assert_eq!(loaded.priority, 3, "priority must be unchanged");
675
676 store.update(&bead.id, None, None, Some(0)).unwrap();
678 let loaded = store.get(&bead.id).unwrap();
679 assert_eq!(loaded.priority, 0);
680 assert_eq!(loaded.status, BeadStatus::InProgress, "status must be unchanged");
681 }
682
683 #[test]
684 fn dep_remove_leaves_other_deps_intact() {
685 let store = test_store();
686 let a = store.create("A", None, 1).unwrap();
687 let b = store.create("B", None, 1).unwrap();
688 let c = store.create("C", None, 1).unwrap();
689
690 store.dep_add(&c.id, &a.id).unwrap();
692 store.dep_add(&c.id, &b.id).unwrap();
693 assert_eq!(store.deps(&c.id).unwrap().len(), 2);
694
695 store.dep_remove(&c.id, &a.id).unwrap();
697 let remaining = store.deps(&c.id).unwrap();
698 assert_eq!(remaining.len(), 1, "after removing one dep, one must remain");
699 assert_eq!(remaining[0], b.id, "the surviving dep must be B");
700
701 let ready = store.ready().unwrap();
703 assert!(
704 !ready.iter().any(|bead| bead.id == c.id),
705 "C should still be blocked by B"
706 );
707 }
708
709 #[test]
710 fn memory_decay_with_no_old_beads_returns_zero() {
711 let store = test_store();
712 let a = store.create("recent A", None, 1).unwrap();
714 store.close(&a.id, Some("done")).unwrap();
715 store.create("still open", None, 2).unwrap();
716
717 let decayed = store.memory_decay(30).unwrap();
718 assert_eq!(decayed, 0, "no beads older than 30d should decay");
719
720 assert!(store.get(&a.id).is_ok(), "recent closed bead must survive");
722
723 let archive_count: i64 = store.conn
725 .query_row("SELECT COUNT(*) FROM archives", [], |r| r.get(0))
726 .unwrap();
727 assert_eq!(archive_count, 0, "no archive row should be created when nothing decayed");
728 }
729
730 #[test]
731 fn list_empty_store_returns_empty_vec() {
732 let store = test_store();
735 assert_eq!(store.list(None, None).unwrap().len(), 0);
736 assert_eq!(store.list(Some("open"), None).unwrap().len(), 0);
737 assert_eq!(store.list(None, Some(0)).unwrap().len(), 0);
738 assert_eq!(store.list(Some("closed"), Some(5)).unwrap().len(), 0);
739 }
740
741 #[test]
742 fn list_combines_status_and_priority_filters() {
743 let store = test_store();
746 let _a = store.create("critical open", None, 0).unwrap();
747 let _b = store.create("normal open", None, 2).unwrap();
748 let c = store.create("critical closed", None, 0).unwrap();
749 store.close(&c.id, Some("done")).unwrap();
750
751 let result = store.list(Some("open"), Some(1)).unwrap();
753 assert_eq!(result.len(), 1);
754 assert_eq!(result[0].title, "critical open");
755
756 let result = store.list(Some("closed"), Some(1)).unwrap();
758 assert_eq!(result.len(), 1);
759 assert_eq!(result[0].title, "critical closed");
760 }
761
762 #[test]
763 fn list_orders_by_priority_ascending() {
764 let store = test_store();
767 store.create("backlog", None, 4).unwrap();
768 store.create("critical", None, 0).unwrap();
769 store.create("normal", None, 2).unwrap();
770
771 let all = store.list(None, None).unwrap();
772 assert_eq!(all.len(), 3);
773 assert_eq!(all[0].priority, 0, "priority 0 must be first");
774 assert_eq!(all[1].priority, 2);
775 assert_eq!(all[2].priority, 4, "priority 4 must be last");
776 }
777
778 #[test]
779 fn ready_treats_missing_dep_as_closed() {
780 let store = test_store();
784 let child = store.create("depends on ghost", None, 1).unwrap();
785
786 store.conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
790 store.conn
791 .execute(
792 "INSERT INTO deps (bead_id, depends_on) VALUES (?1, ?2)",
793 params![child.id.0, "00000000"],
794 )
795 .unwrap();
796 store.conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
797
798 let ready = store.ready().unwrap();
800 assert!(ready.iter().any(|b| b.id == child.id), "dangling dep must not block");
801 }
802
803 #[test]
804 fn dep_add_is_idempotent() {
805 let store = test_store();
808 let a = store.create("A", None, 1).unwrap();
809 let b = store.create("B", None, 1).unwrap();
810
811 store.dep_add(&a.id, &b.id).unwrap();
812 store.dep_add(&a.id, &b.id).unwrap();
813 store.dep_add(&a.id, &b.id).unwrap();
814
815 let deps = store.deps(&a.id).unwrap();
816 assert_eq!(deps.len(), 1, "triple insert must collapse to single dep row");
817 assert_eq!(deps[0], b.id);
818 }
819
820 #[test]
821 fn delete_removes_deps_in_both_directions() {
822 let store = test_store();
826 let a = store.create("A", None, 1).unwrap();
827 let b = store.create("B", None, 1).unwrap();
828 let c = store.create("C", None, 1).unwrap();
829
830 store.dep_add(&a.id, &b.id).unwrap();
832 store.dep_add(&c.id, &b.id).unwrap();
834 assert_eq!(store.deps(&a.id).unwrap().len(), 1);
835 assert_eq!(store.deps(&c.id).unwrap().len(), 1);
836
837 store.delete(&b.id).unwrap();
839
840 assert_eq!(store.deps(&a.id).unwrap().len(), 0, "A→B row must be gone");
841 assert_eq!(store.deps(&c.id).unwrap().len(), 0, "C→B row must be gone");
842
843 assert!(store.get(&a.id).is_ok());
845 assert!(store.get(&c.id).is_ok());
846 }
847
848 #[test]
851 fn test_get_nonexistent_id_returns_not_found() {
852 let store = test_store();
853 let ghost = BeadId("00000000".into());
854 let err = store.get(&ghost).unwrap_err();
855 match err {
856 crate::PawanError::NotFound(msg) => assert!(msg.contains("00000000")),
857 other => panic!("expected NotFound, got {:?}", other),
858 }
859 }
860
861 #[test]
862 fn test_close_sets_closed_at_timestamp() {
863 let store = test_store();
864 let bead = store.create("close-me", None, 2).unwrap();
865 assert!(bead.closed_at.is_none(), "new bead must not have closed_at");
866
867 store.close(&bead.id, Some("finished")).unwrap();
868 let loaded = store.get(&bead.id).unwrap();
869
870 assert!(
871 loaded.closed_at.is_some(),
872 "closed_at must be set after close()"
873 );
874 chrono::DateTime::parse_from_rfc3339(loaded.closed_at.as_deref().unwrap())
876 .expect("closed_at must be valid RFC3339");
877 assert_eq!(loaded.status, BeadStatus::Closed);
878 }
879
880 #[test]
881 fn test_create_without_description_persists_none() {
882 let store = test_store();
883 let bead = store.create("no-desc", None, 1).unwrap();
884 assert!(bead.description.is_none(), "description must be None when not provided");
885
886 let loaded = store.get(&bead.id).unwrap();
888 assert!(loaded.description.is_none(), "DB must store NULL for missing description");
889 }
890
891 #[test]
892 fn test_update_all_none_is_noop() {
893 let store = test_store();
894 let bead = store.create("stable", Some("desc"), 3).unwrap();
895
896 store.update(&bead.id, None, None, None).unwrap();
898 let loaded = store.get(&bead.id).unwrap();
899
900 assert_eq!(loaded.title, "stable", "title must not change");
901 assert_eq!(loaded.description.as_deref(), Some("desc"), "description must not change");
902 assert_eq!(loaded.status, BeadStatus::Open, "status must not change");
903 assert_eq!(loaded.priority, 3, "priority must not change");
904 }
905
906 #[test]
907 fn test_ready_empty_store_returns_empty() {
908 let store = test_store();
909 let ready = store.ready().unwrap();
910 assert!(ready.is_empty(), "empty store must return no ready beads");
911 }
912}