forge_workspace/store/
task_history.rs1use anyhow::Context;
8use forge_primitives::tasks::TaskTransition;
9use redb::{ReadableTable, TableDefinition};
10
11use super::Db;
12
13const TASK_HISTORY: TableDefinition<&str, &[u8]> = TableDefinition::new("task_history");
14
15fn row_key(transition: &TaskTransition) -> String {
18 let nanos =
19 transition.at.duration_since(std::time::SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
20 format!("{}\u{0}{}\u{0}{nanos:040}", transition.project_name, transition.task_id.as_str())
21}
22
23pub fn append(db: &Db, transitions: &[TaskTransition]) -> anyhow::Result<()> {
25 let txn = db.database().begin_write()?;
26 {
27 let mut table = txn.open_table(TASK_HISTORY)?;
28 for transition in transitions {
29 let value = serde_json::to_vec(transition).context("serialize transition")?;
30 table.insert(row_key(transition).as_str(), value.as_slice())?;
31 }
32 }
33 txn.commit()?;
34 Ok(())
35}
36
37pub fn list_for_project(db: &Db, project: &str) -> anyhow::Result<Vec<TaskTransition>> {
39 let txn = db.database().begin_read()?;
40 let table = match txn.open_table(TASK_HISTORY) {
41 Ok(t) => t,
42 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
43 Err(e) => return Err(e.into()),
44 };
45 let prefix = format!("{project}\u{0}");
46 let mut out = Vec::new();
47 for entry in table.iter()? {
48 let (key, value) = entry?;
49 if !key.value().starts_with(&prefix) {
50 continue;
51 }
52 match serde_json::from_slice::<TaskTransition>(value.value()) {
53 Ok(transition) => out.push(transition),
54 Err(err) => tracing::warn!(
55 target: "forge_workspace::store::task_history",
56 row = %key.value(),
57 error = %err,
58 "skipping history row that failed to decode",
59 ),
60 }
61 }
62 Ok(out)
63}
64
65#[cfg(test)]
66mod tests {
67 use std::path::Path;
68
69 use super::*;
70 use forge_primitives::tasks::{By, TaskId, TaskStatus};
71 use tempfile::tempdir;
72
73 fn open_db(dir: &Path) -> Db {
74 Db::open(&dir.join("db.redb")).expect("open db")
75 }
76
77 fn transition(task: &str, project: &str, at_secs: u64, to: TaskStatus) -> TaskTransition {
78 TaskTransition {
79 task_id: TaskId::from(task),
80 project_name: project.to_owned(),
81 from: Some(TaskStatus::Pending),
82 to,
83 at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(at_secs),
84 by: By::System,
85 }
86 }
87
88 #[test]
89 fn history_appends_and_reads_back_for_its_project() {
90 let dir = tempdir().expect("tempdir");
91 let db = open_db(dir.path());
92 append(
93 &db,
94 &[
95 transition("t-1", "forge", 20, TaskStatus::Completed),
96 transition("t-1", "forge", 10, TaskStatus::InProgress),
97 transition("t-2", "other", 30, TaskStatus::InProgress),
98 ],
99 )
100 .expect("append");
101 let forge = list_for_project(&db, "forge").expect("list");
102 assert_eq!(forge.len(), 2, "only forge's transitions: {forge:?}");
103 assert!(forge[0].at < forge[1].at, "oldest first within the task");
104 assert_eq!(forge[0].to, TaskStatus::InProgress);
105 assert_eq!(forge[1].to, TaskStatus::Completed);
106 }
107
108 #[test]
109 fn an_absent_history_table_reads_as_empty() {
110 let dir = tempdir().expect("tempdir");
111 let db = open_db(dir.path());
112 assert!(list_for_project(&db, "forge").expect("list").is_empty());
113 }
114}