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    /// Create a BeadStatus from a string representation
97    ///
98    /// # Arguments
99    /// * `s` - The string to parse ("in_progress", "closed", or any other value for "open")
100    ///
101    /// # Returns
102    /// The corresponding BeadStatus enum variant
103    pub fn from_str(s: &str) -> Self {
104        match s {
105            "in_progress" => Self::InProgress,
106            "closed" => Self::Closed,
107            _ => Self::Open,
108        }
109    }
110}
111
112/// A single bead (task/issue)
113///
114/// Represents a task or issue in the beads system with the following properties:
115/// - `id`: Unique identifier for the bead
116/// - `title`: Short description of the task
117/// - `description`: Optional detailed description
118/// - `status`: Current status (Open, InProgress, Closed)
119/// - `priority`: Priority level (0 = critical, 4 = backlog)
120/// - `created_at`: RFC3339 timestamp when the bead was created
121/// - `updated_at`: RFC3339 timestamp when the bead was last updated
122/// - `closed_at`: Optional RFC3339 timestamp when the bead was closed
123/// - `closed_reason`: Optional reason for closing the bead
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Bead {
126    pub id: BeadId,
127    pub title: String,
128    pub description: Option<String>,
129    pub status: BeadStatus,
130    /// 0 = critical, 4 = backlog
131    pub priority: u8,
132    pub created_at: String,
133    pub updated_at: String,
134    pub closed_at: Option<String>,
135    pub closed_reason: Option<String>,
136}
137
138/// SQLite-backed bead store
139///
140/// BeadStore provides persistent storage for beads (tasks/issues) using SQLite.
141/// It handles creation, retrieval, updating, and deletion of beads, as well as
142/// managing their dependencies and status transitions.
143///
144/// The store is located at `~/.pawan/beads.db` by default.
145///
146/// # Features
147/// - Create, read, update, and delete beads
148/// - Query beads by status, priority, or search term
149/// - Manage bead dependencies
150/// - Track bead history and transitions
151/// - Efficient indexing for large numbers of beads
152pub struct BeadStore {
153    conn: Connection,
154}
155
156impl BeadStore {
157    /// Open or create the bead store
158    pub fn open() -> Result<Self> {
159        let path = Self::db_path()?;
160        if let Some(parent) = path.parent() {
161            std::fs::create_dir_all(parent)
162                .map_err(|e| PawanError::Config(format!("Create dir: {}", e)))?;
163        }
164        let conn = Connection::open(&path)
165            .map_err(|e| PawanError::Config(format!("Open DB: {}", e)))?;
166        let store = Self { conn };
167        store.init_schema()?;
168        Ok(store)
169    }
170
171    /// Open with custom connection (for testing)
172    pub fn with_conn(conn: Connection) -> Result<Self> {
173        let store = Self { conn };
174        store.init_schema()?;
175        Ok(store)
176    }
177
178    fn db_path() -> Result<PathBuf> {
179        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
180        Ok(PathBuf::from(home).join(".pawan").join("beads.db"))
181    }
182
183    fn init_schema(&self) -> Result<()> {
184        self.conn
185            .execute_batch(
186                "CREATE TABLE IF NOT EXISTS beads (
187                    id TEXT PRIMARY KEY,
188                    title TEXT NOT NULL,
189                    description TEXT,
190                    status TEXT NOT NULL DEFAULT 'open',
191                    priority INTEGER NOT NULL DEFAULT 2,
192                    created_at TEXT NOT NULL,
193                    updated_at TEXT NOT NULL,
194                    closed_at TEXT,
195                    closed_reason TEXT
196                );
197                CREATE TABLE IF NOT EXISTS deps (
198                    bead_id TEXT NOT NULL,
199                    depends_on TEXT NOT NULL,
200                    PRIMARY KEY (bead_id, depends_on),
201                    FOREIGN KEY (bead_id) REFERENCES beads(id),
202                    FOREIGN KEY (depends_on) REFERENCES beads(id)
203                );
204                CREATE TABLE IF NOT EXISTS archives (
205                    id INTEGER PRIMARY KEY AUTOINCREMENT,
206                    summary TEXT NOT NULL,
207                    bead_count INTEGER NOT NULL,
208                    archived_at TEXT NOT NULL
209                );",
210            )
211            .map_err(|e| PawanError::Config(format!("Schema: {}", e)))?;
212        Ok(())
213    }
214
215    /// Create a new bead
216    pub fn create(&self, title: &str, description: Option<&str>, priority: u8) -> Result<Bead> {
217        let now = chrono::Utc::now().to_rfc3339();
218        let id = BeadId::generate(title, &now);
219
220        self.conn
221            .execute(
222                "INSERT INTO beads (id, title, description, status, priority, created_at, updated_at)
223                 VALUES (?1, ?2, ?3, 'open', ?4, ?5, ?6)",
224                params![id.0, title, description, priority, now, now],
225            )
226            .map_err(|e| PawanError::Config(format!("Insert: {}", e)))?;
227
228        Ok(Bead {
229            id,
230            title: title.into(),
231            description: description.map(String::from),
232            status: BeadStatus::Open,
233            priority,
234            created_at: now.clone(),
235            updated_at: now,
236            closed_at: None,
237            closed_reason: None,
238        })
239    }
240
241    /// Get a bead by ID
242    pub fn get(&self, id: &BeadId) -> Result<Bead> {
243        self.conn
244            .query_row(
245                "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason
246                 FROM beads WHERE id = ?1",
247                params![id.0],
248                |row| {
249                    Ok(Bead {
250                        id: BeadId(row.get::<_, String>(0)?),
251                        title: row.get(1)?,
252                        description: row.get(2)?,
253                        status: BeadStatus::from_str(&row.get::<_, String>(3)?),
254                        priority: row.get(4)?,
255                        created_at: row.get(5)?,
256                        updated_at: row.get(6)?,
257                        closed_at: row.get(7)?,
258                        closed_reason: row.get(8)?,
259                    })
260                },
261            )
262            .map_err(|e| PawanError::NotFound(format!("Bead {}: {}", id, e)))
263    }
264
265    /// Update a bead's fields
266    pub fn update(
267        &self,
268        id: &BeadId,
269        title: Option<&str>,
270        status: Option<BeadStatus>,
271        priority: Option<u8>,
272    ) -> Result<()> {
273        let now = chrono::Utc::now().to_rfc3339();
274
275        if let Some(t) = title {
276            self.conn
277                .execute(
278                    "UPDATE beads SET title = ?1, updated_at = ?2 WHERE id = ?3",
279                    params![t, now, id.0],
280                )
281                .map_err(|e| PawanError::Config(format!("Update title: {}", e)))?;
282        }
283        if let Some(s) = status {
284            self.conn
285                .execute(
286                    "UPDATE beads SET status = ?1, updated_at = ?2 WHERE id = ?3",
287                    params![s.to_str(), now, id.0],
288                )
289                .map_err(|e| PawanError::Config(format!("Update status: {}", e)))?;
290        }
291        if let Some(p) = priority {
292            self.conn
293                .execute(
294                    "UPDATE beads SET priority = ?1, updated_at = ?2 WHERE id = ?3",
295                    params![p, now, id.0],
296                )
297                .map_err(|e| PawanError::Config(format!("Update priority: {}", e)))?;
298        }
299        Ok(())
300    }
301
302    /// Close a bead with optional reason
303    pub fn close(&self, id: &BeadId, reason: Option<&str>) -> Result<()> {
304        let now = chrono::Utc::now().to_rfc3339();
305        self.conn
306            .execute(
307                "UPDATE beads SET status = 'closed', closed_at = ?1, closed_reason = ?2, updated_at = ?3 WHERE id = ?4",
308                params![now, reason, now, id.0],
309            )
310            .map_err(|e| PawanError::Config(format!("Close: {}", e)))?;
311        Ok(())
312    }
313
314    /// Delete a bead
315    pub fn delete(&self, id: &BeadId) -> Result<()> {
316        self.conn
317            .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id.0])
318            .map_err(|e| PawanError::Config(format!("Delete deps: {}", e)))?;
319        self.conn
320            .execute("DELETE FROM beads WHERE id = ?1", params![id.0])
321            .map_err(|e| PawanError::Config(format!("Delete: {}", e)))?;
322        Ok(())
323    }
324
325    /// List beads with optional filters
326    pub fn list(
327        &self,
328        status: Option<&str>,
329        max_priority: Option<u8>,
330    ) -> Result<Vec<Bead>> {
331        let mut sql = "SELECT id, title, description, status, priority, created_at, updated_at, closed_at, closed_reason FROM beads WHERE 1=1".to_string();
332        let mut bind_vals: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
333
334        if let Some(s) = status {
335            sql.push_str(&format!(" AND status = ?{}", bind_vals.len() + 1));
336            bind_vals.push(Box::new(s.to_string()));
337        }
338        if let Some(p) = max_priority {
339            sql.push_str(&format!(" AND priority <= ?{}", bind_vals.len() + 1));
340            bind_vals.push(Box::new(p));
341        }
342        sql.push_str(" ORDER BY priority ASC, updated_at DESC");
343
344        let params_refs: Vec<&dyn rusqlite::types::ToSql> = bind_vals.iter().map(|b| b.as_ref()).collect();
345
346        let mut stmt = self.conn.prepare(&sql)
347            .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
348
349        let beads = stmt
350            .query_map(params_refs.as_slice(), |row| {
351                Ok(Bead {
352                    id: BeadId(row.get::<_, String>(0)?),
353                    title: row.get(1)?,
354                    description: row.get(2)?,
355                    status: BeadStatus::from_str(&row.get::<_, String>(3)?),
356                    priority: row.get(4)?,
357                    created_at: row.get(5)?,
358                    updated_at: row.get(6)?,
359                    closed_at: row.get(7)?,
360                    closed_reason: row.get(8)?,
361                })
362            })
363            .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
364            .filter_map(|r| r.ok())
365            .collect();
366
367        Ok(beads)
368    }
369
370    /// Add a dependency: bead_id depends on depends_on
371    pub fn dep_add(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
372        self.conn
373            .execute(
374                "INSERT OR IGNORE INTO deps (bead_id, depends_on) VALUES (?1, ?2)",
375                params![bead_id.0, depends_on.0],
376            )
377            .map_err(|e| PawanError::Config(format!("Dep add: {}", e)))?;
378        Ok(())
379    }
380
381    /// Remove a dependency
382    pub fn dep_remove(&self, bead_id: &BeadId, depends_on: &BeadId) -> Result<()> {
383        self.conn
384            .execute(
385                "DELETE FROM deps WHERE bead_id = ?1 AND depends_on = ?2",
386                params![bead_id.0, depends_on.0],
387            )
388            .map_err(|e| PawanError::Config(format!("Dep rm: {}", e)))?;
389        Ok(())
390    }
391
392    /// Get dependencies of a bead
393    pub fn deps(&self, bead_id: &BeadId) -> Result<Vec<BeadId>> {
394        let mut stmt = self.conn
395            .prepare("SELECT depends_on FROM deps WHERE bead_id = ?1")
396            .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
397
398        let ids = stmt
399            .query_map(params![bead_id.0], |row| {
400                Ok(BeadId(row.get::<_, String>(0)?))
401            })
402            .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
403            .filter_map(|r| r.ok())
404            .collect();
405
406        Ok(ids)
407    }
408
409    /// 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.conn
436            .prepare(
437                "SELECT id, title, closed_reason FROM beads
438                 WHERE status = 'closed' AND closed_at < ?1
439                 ORDER BY closed_at ASC",
440            )
441            .map_err(|e| PawanError::Config(format!("Prepare: {}", e)))?;
442
443        let old_beads: Vec<(String, String, Option<String>)> = stmt
444            .query_map(params![cutoff_str], |row| {
445                Ok((
446                    row.get::<_, String>(0)?,
447                    row.get::<_, String>(1)?,
448                    row.get::<_, Option<String>>(2)?,
449                ))
450            })
451            .map_err(|e| PawanError::Config(format!("Query: {}", e)))?
452            .filter_map(|r| r.ok())
453            .collect();
454
455        if old_beads.is_empty() {
456            return Ok(0);
457        }
458
459        let count = old_beads.len();
460
461        // Build summary
462        let summary_lines: Vec<String> = old_beads
463            .iter()
464            .map(|(id, title, reason)| {
465                let r = reason.as_deref().unwrap_or("done");
466                format!("- bd-{}: {} ({})", id, title, r)
467            })
468            .collect();
469        let summary = format!(
470            "Archived {} beads (before {}):\n{}",
471            count,
472            cutoff_str,
473            summary_lines.join("\n")
474        );
475
476        let now = chrono::Utc::now().to_rfc3339();
477        self.conn
478            .execute(
479                "INSERT INTO archives (summary, bead_count, archived_at) VALUES (?1, ?2, ?3)",
480                params![summary, count, now],
481            )
482            .map_err(|e| PawanError::Config(format!("Archive: {}", e)))?;
483
484        // Delete archived beads
485        for (id, _, _) in &old_beads {
486            self.conn
487                .execute("DELETE FROM deps WHERE bead_id = ?1 OR depends_on = ?1", params![id])
488                .ok();
489            self.conn
490                .execute("DELETE FROM beads WHERE id = ?1", params![id])
491                .ok();
492        }
493
494        Ok(count)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn test_store() -> BeadStore {
503        let conn = Connection::open_in_memory().unwrap();
504        BeadStore::with_conn(conn).unwrap()
505    }
506
507    #[test]
508    fn create_and_get() {
509        let store = test_store();
510        let bead = store.create("Fix bug", Some("It's broken"), 1).unwrap();
511        assert!(bead.id.0.len() == 8);
512        assert_eq!(bead.title, "Fix bug");
513        assert_eq!(bead.priority, 1);
514
515        let loaded = store.get(&bead.id).unwrap();
516        assert_eq!(loaded.title, "Fix bug");
517    }
518
519    #[test]
520    fn list_filters() {
521        let store = test_store();
522        store.create("A", None, 0).unwrap();
523        store.create("B", None, 2).unwrap();
524        let c = store.create("C", None, 4).unwrap();
525        store.close(&c.id, Some("done")).unwrap();
526
527        let all = store.list(None, None).unwrap();
528        assert_eq!(all.len(), 3);
529
530        let open = store.list(Some("open"), None).unwrap();
531        assert_eq!(open.len(), 2);
532
533        let critical = store.list(None, Some(1)).unwrap();
534        assert_eq!(critical.len(), 1);
535        assert_eq!(critical[0].title, "A");
536    }
537
538    #[test]
539    fn deps_and_ready() {
540        let store = test_store();
541        let a = store.create("Task A", None, 1).unwrap();
542        let b = store.create("Task B", None, 1).unwrap();
543        let c = store.create("Task C", None, 1).unwrap();
544
545        // C depends on A and B
546        store.dep_add(&c.id, &a.id).unwrap();
547        store.dep_add(&c.id, &b.id).unwrap();
548
549        // Only A and B should be ready (C is blocked)
550        let ready = store.ready().unwrap();
551        assert_eq!(ready.len(), 2);
552        let ready_ids: Vec<&str> = ready.iter().map(|b| b.id.0.as_str()).collect();
553        assert!(!ready_ids.contains(&c.id.0.as_str()));
554
555        // Close A — C still blocked by B
556        store.close(&a.id, None).unwrap();
557        let ready = store.ready().unwrap();
558        assert_eq!(ready.len(), 1); // only B
559        assert_eq!(ready[0].id, b.id);
560
561        // Close B — C now ready
562        store.close(&b.id, None).unwrap();
563        let ready = store.ready().unwrap();
564        assert_eq!(ready.len(), 1);
565        assert_eq!(ready[0].id, c.id);
566    }
567
568    #[test]
569    fn close_and_delete() {
570        let store = test_store();
571        let bead = store.create("Temp", None, 3).unwrap();
572
573        store.close(&bead.id, Some("no longer needed")).unwrap();
574        let loaded = store.get(&bead.id).unwrap();
575        assert_eq!(loaded.status, BeadStatus::Closed);
576        assert_eq!(loaded.closed_reason.as_deref(), Some("no longer needed"));
577
578        store.delete(&bead.id).unwrap();
579        assert!(store.get(&bead.id).is_err());
580    }
581
582    #[test]
583    fn memory_decay_archives() {
584        let store = test_store();
585
586        // Create and close a bead with old timestamp
587        let bead = store.create("Old task", None, 2).unwrap();
588        let old_time = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
589        store.conn
590            .execute(
591                "UPDATE beads SET status = 'closed', closed_at = ?1 WHERE id = ?2",
592                params![old_time, bead.id.0],
593            )
594            .unwrap();
595
596        // Create a recent closed bead (should NOT be decayed)
597        let recent = store.create("Recent task", None, 2).unwrap();
598        store.close(&recent.id, Some("just done")).unwrap();
599
600        // Decay beads older than 30 days
601        let count = store.memory_decay(30).unwrap();
602        assert_eq!(count, 1);
603
604        // Old bead should be gone
605        assert!(store.get(&bead.id).is_err());
606        // Recent bead should remain
607        assert!(store.get(&recent.id).is_ok());
608
609        // Archive should exist
610        let summary: String = store.conn
611            .query_row("SELECT summary FROM archives ORDER BY id DESC LIMIT 1", [], |r| r.get(0))
612            .unwrap();
613        assert!(summary.contains("Old task"));
614    }
615}