Skip to main content

pawan/
tasks.rs

1//! Beads-style task tracking — hash IDs, dependency graphs, memory decay
2//!
3//! Inspired by Steve Yegge's Beads. Each task ("bead") has a content-addressable
4//! hash ID (bd-XXXXXXXX), can depend on other beads, and supports memory decay
5//! (old closed tasks get summarized to save context window).
6//!
7//! Storage: SQLite at ~/.pawan/beads.db
8
9use crate::{PawanError, Result};
10use rusqlite::{params, Connection};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14/// 8-char hash prefix, displayed as bd-{hash}
15///
16/// BeadId is a content-addressable identifier for beads (tasks/issues).
17/// It's generated from the title and creation timestamp using a hash function.
18/// The ID is represented as an 8-character hexadecimal string and displayed with the "bd-" prefix.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct BeadId(pub String);
21
22impl BeadId {
23    /// Generate a new BeadId from a title and timestamp
24    ///
25    /// # Arguments
26    /// * `title` - The title of the task/bead
27    /// * `created_at` - The creation timestamp in RFC3339 format
28    ///
29    /// # Returns
30    /// A new BeadId with an 8-character hash prefix
31    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    /// Display the BeadId in the standard format "bd-XXXXXXXX"
42    ///
43    /// # Returns
44    /// A formatted string representation of the BeadId
45    pub fn display(&self) -> String {
46        format!("bd-{}", self.0)
47    }
48
49    /// Parse a BeadId from a string representation
50    ///
51    /// Accepts both "bd-XXXXXXXX" and "XXXXXXXX" formats
52    ///
53    /// # Arguments
54    /// * `s` - The string to parse
55    ///
56    /// # Returns
57    /// A BeadId parsed from the string
58    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/// Task status
70///
71/// Represents the current state of a bead (task/issue):
72/// - `Open`: Task is created but not yet started
73/// - `InProgress`: Task is actively being worked on
74/// - `Closed`: Task is completed or abandoned
75#[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    /// Convert the BeadStatus to a string representation
85    ///
86    /// # Returns
87    /// A string slice representing the status ("open", "in_progress", or "closed")
88    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/// A single bead (task/issue)
110///
111/// Represents a task or issue in the beads system with the following properties:
112/// - `id`: Unique identifier for the bead
113/// - `title`: Short description of the task
114/// - `description`: Optional detailed description
115/// - `status`: Current status (Open, InProgress, Closed)
116/// - `priority`: Priority level (0 = critical, 4 = backlog)
117/// - `created_at`: RFC3339 timestamp when the bead was created
118/// - `updated_at`: RFC3339 timestamp when the bead was last updated
119/// - `closed_at`: Optional RFC3339 timestamp when the bead was closed
120/// - `closed_reason`: Optional reason for closing the bead
121#[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    /// 0 = critical, 4 = backlog
128    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
135/// SQLite-backed bead store
136///
137/// BeadStore provides persistent storage for beads (tasks/issues) using SQLite.
138/// It handles creation, retrieval, updating, and deletion of beads, as well as
139/// managing their dependencies and status transitions.
140///
141/// The store is located at `~/.pawan/beads.db` by default.
142///
143/// # Features
144/// - Create, read, update, and delete beads
145/// - Query beads by status, priority, or search term
146/// - Manage bead dependencies
147/// - Track bead history and transitions
148/// - Efficient indexing for large numbers of beads
149pub struct BeadStore {
150    conn: Connection,
151}
152
153impl BeadStore {
154    /// Open or create the bead store
155    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    /// Open with custom connection (for testing)
169    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    /// Create a new bead
213    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    /// Get a bead by ID
239    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    /// Update a bead's fields
263    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    /// Close a bead with optional reason
300    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    /// Delete a bead
312    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    /// List beads with optional filters
326    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    /// Add a dependency: bead_id depends on depends_on
370    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    /// Remove a dependency
381    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    /// Get dependencies of a bead
392    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    /// Ready beads: Open beads whose ALL dependencies are Closed
410    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) // missing dep = treat as closed
420            });
421            if all_closed {
422                ready.push(bead);
423            }
424        }
425
426        Ok(ready)
427    }
428
429    /// Memory decay: summarize closed beads older than max_age_days into archive
430    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        // Find old closed beads
435        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        // Build summary
463        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        // Delete archived beads
486        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        // C depends on A and B
550        store.dep_add(&c.id, &a.id).unwrap();
551        store.dep_add(&c.id, &b.id).unwrap();
552
553        // Only A and B should be ready (C is blocked)
554        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        // Close A — C still blocked by B
560        store.close(&a.id, None).unwrap();
561        let ready = store.ready().unwrap();
562        assert_eq!(ready.len(), 1); // only B
563        assert_eq!(ready[0].id, b.id);
564
565        // Close B — C now ready
566        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        // Create and close a bead with old timestamp
591        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        // Create a recent closed bead (should NOT be decayed)
602        let recent = store.create("Recent task", None, 2).unwrap();
603        store.close(&recent.id, Some("just done")).unwrap();
604
605        // Decay beads older than 30 days
606        let count = store.memory_decay(30).unwrap();
607        assert_eq!(count, 1);
608
609        // Old bead should be gone
610        assert!(store.get(&bead.id).is_err());
611        // Recent bead should remain
612        assert!(store.get(&recent.id).is_ok());
613
614        // Archive should exist
615        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        // Same title + timestamp must hash to the same id. This is the
629        // content-addressable guarantee the "bd-" namespace relies on.
630        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        // Different inputs must differ
635        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        // Both "bd-XXXXXXXX" and bare "XXXXXXXX" must round-trip to the
642        // same stored id, so users can type either form in tools.
643        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        // Display always adds the prefix back
648        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        // Known variants round-trip
656        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        // Unknown string defaults to Open (permissive parse per impl)
663        assert_eq!(BeadStatus::from_str("garbage").unwrap(), BeadStatus::Open);
664        assert_eq!(BeadStatus::from_str("").unwrap(), BeadStatus::Open);
665        // to_str() / from_str() round trip
666        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        // Update title only
678        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        // Update status only
685        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        // Update priority only
694        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        // C depends on A and B
712        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        // Remove only the dep on A — dep on B must remain
717        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        // C is still blocked by B
727        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        // Only recent beads — nothing should be decayed
738        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        // Both beads must still exist
746        assert!(store.get(&a.id).is_ok(), "recent closed bead must survive");
747
748        // No archive row should have been inserted
749        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        // Boundary: brand-new store, no beads. list() must return Ok([])
762        // not error, so callers can skip the Err branch.
763        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        // Both filters in one query hit the dual-AND branch in list() —
773        // previously I only saw them tested individually.
774        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        // open AND priority <= 1 → only "critical open"
781        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        // closed AND priority <= 1 → only "critical closed"
786        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        // ORDER BY priority ASC — critical (0) beats backlog (4). If the
794        // sort direction flips, cli consumers get surprise ordering.
795        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        // The impl comment at tasks.rs:417 says "missing dep = treat as
810        // closed" — if a bead depends on an id that doesn't resolve, the
811        // bead should still become ready rather than stuck forever.
812        let store = test_store();
813        let child = store.create("depends on ghost", None, 1).unwrap();
814
815        // Disable FK enforcement just long enough to insert a dangling
816        // dep row — this simulates the real-world scenario where a bead
817        // was removed out of band (e.g. via a raw SQL migration).
818        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        // child should now be ready despite the stale dep row
829        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        // INSERT OR IGNORE — calling dep_add twice with the same pair
839        // must not error and must not create duplicate rows.
840        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        // delete() must clean deps where the bead appears as either
860        // bead_id OR depends_on — otherwise deleting A while B depends on
861        // it leaves orphan rows pointing at nothing.
862        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        // A depends on B (A is bead_id, B is depends_on)
868        store.dep_add(&a.id, &b.id).unwrap();
869        // C depends on B
870        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        // Delete B — both A→B and C→B rows must be removed
875        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        // A and C themselves must still exist
881        assert!(store.get(&a.id).is_ok());
882        assert!(store.get(&c.id).is_ok());
883    }
884
885    // ─── Additional gap coverage ──────────────────────────────────────────
886
887    #[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        // Must parse as valid RFC3339
912        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        // Verify the DB also stores NULL (not an empty string)
927        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        // Call update with all-None — nothing should change
940        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}