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