forge_workspace/store/
task_archive.rs1use anyhow::Context;
7use forge_primitives::tasks::Task;
8use redb::{ReadableTable, TableDefinition};
9
10use super::Db;
11
12const TASK_ARCHIVE: TableDefinition<&str, &[u8]> = TableDefinition::new("tasks_archive");
13
14fn row_key(task: &Task) -> String {
16 format!("{}\u{0}{}", task.project_name, task.id.as_str())
17}
18
19pub fn append(db: &Db, tasks: &[Task]) -> anyhow::Result<()> {
21 let txn = db.database().begin_write()?;
22 {
23 let mut table = txn.open_table(TASK_ARCHIVE)?;
24 for task in tasks {
25 let value = serde_json::to_vec(task).context("serialize archived task")?;
26 table.insert(row_key(task).as_str(), value.as_slice())?;
27 }
28 }
29 txn.commit()?;
30 Ok(())
31}
32
33pub fn list_for_project(db: &Db, project: &str) -> anyhow::Result<Vec<Task>> {
35 let txn = db.database().begin_read()?;
36 let table = match txn.open_table(TASK_ARCHIVE) {
37 Ok(t) => t,
38 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
39 Err(e) => return Err(e.into()),
40 };
41 let prefix = format!("{project}\u{0}");
42 let mut out = Vec::new();
43 for entry in table.iter()? {
44 let (key, value) = entry?;
45 if !key.value().starts_with(&prefix) {
46 continue;
47 }
48 match serde_json::from_slice::<Task>(value.value()) {
49 Ok(task) => out.push(task),
50 Err(err) => tracing::warn!(
51 target: "forge_workspace::store::task_archive",
52 row = %key.value(),
53 error = %err,
54 "skipping archived row that failed to decode",
55 ),
56 }
57 }
58 Ok(out)
59}
60
61#[cfg(test)]
62mod tests {
63 use std::path::Path;
64
65 use super::*;
66 use forge_primitives::tasks::{Task, TaskId, TaskStatus};
67 use tempfile::tempdir;
68
69 fn open_db(dir: &Path) -> Db {
70 Db::open(&dir.join("db.redb")).expect("open db")
71 }
72
73 fn archived_task(id: &str, project: &str) -> Task {
74 Task {
75 id: TaskId::from(id),
76 project_name: project.to_owned(),
77 subject: format!("subject {id}"),
78 active_form: None,
79 detail: None,
80 status: TaskStatus::Completed,
81 owner: None,
82 parent: None,
83 waiting_on: None,
84 estimate: None,
85 rank: None,
86 verify: None,
87 attested: None,
88 reserved: None,
89 links: Vec::new(),
90 attempt: 0,
91 archived_at: Some(std::time::SystemTime::UNIX_EPOCH),
92 created_at: std::time::SystemTime::UNIX_EPOCH,
93 updated_at: std::time::SystemTime::UNIX_EPOCH,
94 }
95 }
96
97 #[test]
98 fn archived_rows_append_and_read_back_for_their_project() {
99 let dir = tempdir().expect("tempdir");
100 let db = open_db(dir.path());
101 append(&db, &[archived_task("t-1", "forge"), archived_task("t-2", "other")])
102 .expect("append");
103 let forge = list_for_project(&db, "forge").expect("list");
104 assert_eq!(forge.len(), 1, "only forge's archived rows: {forge:?}");
105 assert_eq!(forge[0].id, TaskId::from("t-1"));
106 assert!(forge[0].archived_at.is_some(), "an archived row carries its stamp");
107 }
108
109 #[test]
110 fn an_absent_archive_table_reads_as_empty() {
111 let dir = tempdir().expect("tempdir");
112 let db = open_db(dir.path());
113 assert!(list_for_project(&db, "forge").expect("list").is_empty());
114 }
115}