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}
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/// A single bead (task/issue)
111///
112/// Represents a task or issue in the beads system with the following properties:
113/// - `id`: Unique identifier for the bead
114/// - `title`: Short description of the task
115/// - `description`: Optional detailed description
116/// - `status`: Current status (Open, InProgress, Closed)
117/// - `priority`: Priority level (0 = critical, 4 = backlog)
118/// - `created_at`: RFC3339 timestamp when the bead was created
119/// - `updated_at`: RFC3339 timestamp when the bead was last updated
120/// - `closed_at`: Optional RFC3339 timestamp when the bead was closed
121/// - `closed_reason`: Optional reason for closing the bead
122#[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    /// 0 = critical, 4 = backlog
129    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
136/// SQLite-backed bead store
137///
138/// BeadStore provides persistent storage for beads (tasks/issues) using SQLite.
139/// It handles creation, retrieval, updating, and deletion of beads, as well as
140/// managing their dependencies and status transitions.
141///
142/// The store is located at `~/.pawan/beads.db` by default.
143///
144/// # Features
145/// - Create, read, update, and delete beads
146/// - Query beads by status, priority, or search term
147/// - Manage bead dependencies
148/// - Track bead history and transitions
149/// - Efficient indexing for large numbers of beads
150pub struct BeadStore {
151    conn: Connection,
152}
153
154impl BeadStore {
155    /// Open or create the bead store
156    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    /// Open with custom connection (for testing)
170    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    /// Create a new bead
214    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    /// Get a bead by ID
240    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    /// Update a bead's fields
264    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    /// Close a bead with optional reason
301    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    /// Delete a bead
313    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    /// List beads with optional filters
324    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    /// Add a dependency: bead_id depends on depends_on
369    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    /// Remove a dependency
380    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    /// Get dependencies of a bead
391    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    /// Ready beads: Open beads whose ALL dependencies are Closed
408    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) // missing dep = treat as closed
418            });
419            if all_closed {
420                ready.push(bead);
421            }
422        }
423
424        Ok(ready)
425    }
426
427    /// Memory decay: summarize closed beads older than max_age_days into archive
428    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        // Find old closed beads
433        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        // Build summary
460        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        // Delete archived beads
483        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        // C depends on A and B
544        store.dep_add(&c.id, &a.id).unwrap();
545        store.dep_add(&c.id, &b.id).unwrap();
546
547        // Only A and B should be ready (C is blocked)
548        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        // Close A — C still blocked by B
554        store.close(&a.id, None).unwrap();
555        let ready = store.ready().unwrap();
556        assert_eq!(ready.len(), 1); // only B
557        assert_eq!(ready[0].id, b.id);
558
559        // Close B — C now ready
560        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        // Create and close a bead with old timestamp
585        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        // Create a recent closed bead (should NOT be decayed)
595        let recent = store.create("Recent task", None, 2).unwrap();
596        store.close(&recent.id, Some("just done")).unwrap();
597
598        // Decay beads older than 30 days
599        let count = store.memory_decay(30).unwrap();
600        assert_eq!(count, 1);
601
602        // Old bead should be gone
603        assert!(store.get(&bead.id).is_err());
604        // Recent bead should remain
605        assert!(store.get(&recent.id).is_ok());
606
607        // Archive should exist
608        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        // Same title + timestamp must hash to the same id. This is the
617        // content-addressable guarantee the "bd-" namespace relies on.
618        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        // Different inputs must differ
623        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        // Both "bd-XXXXXXXX" and bare "XXXXXXXX" must round-trip to the
630        // same stored id, so users can type either form in tools.
631        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        // Display always adds the prefix back
636        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        // Known variants round-trip
644        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        // Unknown string defaults to Open (permissive parse per impl)
648        assert_eq!(BeadStatus::from_str("garbage").unwrap(), BeadStatus::Open);
649        assert_eq!(BeadStatus::from_str("").unwrap(), BeadStatus::Open);
650        // to_str() / from_str() round trip
651        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        // Update title only
663        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        // Update status only
670        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        // Update priority only
677        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        // C depends on A and B
691        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        // Remove only the dep on A — dep on B must remain
696        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        // C is still blocked by B
702        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        // Only recent beads — nothing should be decayed
713        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        // Both beads must still exist
721        assert!(store.get(&a.id).is_ok(), "recent closed bead must survive");
722
723        // No archive row should have been inserted
724        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        // Boundary: brand-new store, no beads. list() must return Ok([])
733        // not error, so callers can skip the Err branch.
734        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        // Both filters in one query hit the dual-AND branch in list() —
744        // previously I only saw them tested individually.
745        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        // open AND priority <= 1 → only "critical open"
752        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        // closed AND priority <= 1 → only "critical closed"
757        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        // ORDER BY priority ASC — critical (0) beats backlog (4). If the
765        // sort direction flips, cli consumers get surprise ordering.
766        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        // The impl comment at tasks.rs:417 says "missing dep = treat as
781        // closed" — if a bead depends on an id that doesn't resolve, the
782        // bead should still become ready rather than stuck forever.
783        let store = test_store();
784        let child = store.create("depends on ghost", None, 1).unwrap();
785
786        // Disable FK enforcement just long enough to insert a dangling
787        // dep row — this simulates the real-world scenario where a bead
788        // was removed out of band (e.g. via a raw SQL migration).
789        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        // child should now be ready despite the stale dep row
799        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        // INSERT OR IGNORE — calling dep_add twice with the same pair
806        // must not error and must not create duplicate rows.
807        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        // delete() must clean deps where the bead appears as either
823        // bead_id OR depends_on — otherwise deleting A while B depends on
824        // it leaves orphan rows pointing at nothing.
825        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        // A depends on B (A is bead_id, B is depends_on)
831        store.dep_add(&a.id, &b.id).unwrap();
832        // C depends on B
833        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        // Delete B — both A→B and C→B rows must be removed
838        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        // A and C themselves must still exist
844        assert!(store.get(&a.id).is_ok());
845        assert!(store.get(&c.id).is_ok());
846    }
847
848    // ─── Additional gap coverage ──────────────────────────────────────────
849
850    #[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        // Must parse as valid RFC3339
875        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        // Verify the DB also stores NULL (not an empty string)
887        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        // Call update with all-None — nothing should change
897        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}