A file-based task manager
1//! A task: a tree of `{content, <prop>...}` blobs with its own commit history.
2//!
3//! Stable id = SHA-1 hex of the initial `content` blob. Refs:
4//! `refs/tsk/tasks/<stable-id>` → latest commit on that task.
5//!
6//! Tree layout for a task at any commit:
7//! content → blob: full task body, first line is the title
8//! <prop-key> → blob: property value (one file per property)
9//!
10//! Older trees may also contain a `title` blob (a cache of content's first
11//! line). It's ignored on read and silently dropped on the next write.
12
13use crate::errors::Result;
14use crate::propvalue;
15use git2::{Oid, Repository, Signature};
16use std::collections::BTreeMap;
17use std::fmt::Display;
18
19pub const TASK_REF_PREFIX: &str = "refs/tsk/tasks/";
20pub const CONTENT_FILE: &str = "content";
21pub const TITLE_FILE: &str = "title";
22
23/// Stable identifier for a task: hex SHA-1 of its initial content blob.
24#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
25pub struct StableId(pub String);
26
27impl StableId {
28 pub fn refname(&self) -> String {
29 format!("{TASK_REF_PREFIX}{}", self.0)
30 }
31 #[allow(dead_code)] // used by upcoming display layer
32 pub fn short(&self) -> &str {
33 &self.0[..12.min(self.0.len())]
34 }
35}
36
37impl Display for StableId {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(&self.0)
40 }
41}
42
43#[derive(Clone, Debug, Default, Eq, PartialEq)]
44pub struct Task {
45 pub content: String,
46 /// Each property is zero or more text values (one per line in storage).
47 pub properties: BTreeMap<String, Vec<String>>,
48}
49
50impl Task {
51 pub fn new(content: impl Into<String>) -> Self {
52 Self {
53 content: content.into(),
54 properties: BTreeMap::new(),
55 }
56 }
57
58 pub fn title(&self) -> &str {
59 self.content.lines().next().unwrap_or("")
60 }
61
62 pub fn body(&self) -> &str {
63 match self.content.split_once('\n') {
64 Some((_, rest)) => rest.trim_start_matches('\n'),
65 None => "",
66 }
67 }
68}
69
70/// Local user's git signature, with a `tsk@local` fallback. Shared by
71/// every writer so commits all carry the same author/committer.
72pub(crate) fn signature(repo: &Repository) -> Signature<'static> {
73 repo.signature()
74 .map(|s| s.to_owned())
75 .unwrap_or_else(|_| Signature::now("tsk", "tsk@local").unwrap())
76}
77
78fn build_tree(
79 repo: &Repository,
80 content_oid: Oid,
81 properties: &BTreeMap<String, Vec<String>>,
82) -> Result<Oid> {
83 let mut tb = repo.treebuilder(None)?;
84 tb.insert(CONTENT_FILE, content_oid, 0o100644)?;
85 for (k, values) in properties {
86 if k == CONTENT_FILE || k == TITLE_FILE {
87 continue;
88 }
89 let body = propvalue::encode(values);
90 let oid = repo.blob(&body)?;
91 tb.insert(k.as_str(), oid, 0o100644)?;
92 }
93 Ok(tb.write()?)
94}
95
96/// Create a brand-new task. Returns its freshly-minted stable id.
97pub fn create(repo: &Repository, task: &Task, message: &str) -> Result<StableId> {
98 let content_oid = repo.blob(task.content.as_bytes())?;
99 let stable = StableId(content_oid.to_string());
100 let tree_oid = build_tree(repo, content_oid, &task.properties)?;
101 let sig = signature(repo);
102 let commit = repo.commit(
103 None,
104 &sig,
105 &sig,
106 message,
107 &repo.find_tree(tree_oid)?,
108 &[],
109 )?;
110 repo.reference(&stable.refname(), commit, true, message)?;
111 Ok(stable)
112}
113
114/// Append a new commit to a task's history. Returns `true` if a commit
115/// was actually written; `false` when the resulting tree matches the
116/// parent's (idempotent no-op).
117pub fn update(repo: &Repository, id: &StableId, task: &Task, message: &str) -> Result<bool> {
118 let content_oid = repo.blob(task.content.as_bytes())?;
119 let tree_oid = build_tree(repo, content_oid, &task.properties)?;
120 let parent = repo
121 .find_reference(&id.refname())
122 .ok()
123 .and_then(|r| r.target())
124 .and_then(|o| repo.find_commit(o).ok());
125 if let Some(p) = &parent
126 && p.tree_id() == tree_oid
127 {
128 return Ok(false);
129 }
130 let sig = signature(repo);
131 let parents: Vec<&git2::Commit> = parent.iter().collect();
132 let commit = repo.commit(
133 None,
134 &sig,
135 &sig,
136 message,
137 &repo.find_tree(tree_oid)?,
138 &parents,
139 )?;
140 repo.reference(&id.refname(), commit, true, message)?;
141 Ok(true)
142}
143
144pub fn read(repo: &Repository, id: &StableId) -> Result<Option<Task>> {
145 let Ok(r) = repo.find_reference(&id.refname()) else {
146 return Ok(None);
147 };
148 let Some(target) = r.target() else {
149 return Ok(None);
150 };
151 let commit = repo.find_commit(target)?;
152 let tree = commit.tree()?;
153 let mut task = Task::default();
154 for entry in tree.iter() {
155 let name = entry.name().unwrap_or("").to_string();
156 let blob = entry.to_object(repo)?.peel_to_blob()?;
157 let val = String::from_utf8_lossy(blob.content()).into_owned();
158 match name.as_str() {
159 CONTENT_FILE => task.content = val,
160 TITLE_FILE => {} // cache only; canonical title is content's first line
161 _ => {
162 let values = propvalue::decode(blob.content());
163 task.properties.insert(name, values);
164 }
165 }
166 }
167 Ok(Some(task))
168}
169
170#[allow(dead_code)] // exposed for cleanup tooling / future commands
171pub fn delete(repo: &Repository, id: &StableId) -> Result<()> {
172 if let Ok(mut r) = repo.find_reference(&id.refname()) {
173 r.delete()?;
174 }
175 Ok(())
176}
177
178#[allow(dead_code)] // exposed for cleanup tooling / future commands
179pub fn list_all(repo: &Repository) -> Result<Vec<StableId>> {
180 let mut out = Vec::new();
181 for r in repo.references_glob(&format!("{TASK_REF_PREFIX}*"))? {
182 let r = r?;
183 if let Some(name) = r.name()
184 && let Some(rest) = name.strip_prefix(TASK_REF_PREFIX)
185 {
186 out.push(StableId(rest.to_string()));
187 }
188 }
189 Ok(out)
190}
191
192#[cfg(test)]
193mod test {
194 use super::*;
195 use std::path::Path;
196
197 fn init_repo(p: &Path) -> Repository {
198 let r = Repository::init(p).unwrap();
199 let mut cfg = r.config().unwrap();
200 cfg.set_str("user.name", "Test").unwrap();
201 cfg.set_str("user.email", "t@e").unwrap();
202 r
203 }
204
205 #[test]
206 fn create_read_round_trip() {
207 let dir = tempfile::tempdir().unwrap();
208 let repo = init_repo(dir.path());
209 let mut t = Task::new("Hello\n\nbody text");
210 t.properties
211 .insert("priority".into(), vec!["high".into()]);
212 t.properties
213 .insert("tag".into(), vec!["alpha".into(), "beta".into()]);
214 let id = create(&repo, &t, "create").unwrap();
215 let read_back = read(&repo, &id).unwrap().unwrap();
216 assert_eq!(read_back.content, t.content);
217 assert_eq!(read_back.properties, t.properties);
218 assert_eq!(read_back.title(), "Hello");
219 assert_eq!(read_back.body(), "body text");
220 }
221
222 #[test]
223 fn update_appends_commit() {
224 let dir = tempfile::tempdir().unwrap();
225 let repo = init_repo(dir.path());
226 let t = Task::new("v1");
227 let id = create(&repo, &t, "create").unwrap();
228 let mut t2 = t.clone();
229 t2.content = "v2".into();
230 update(&repo, &id, &t2, "edit").unwrap();
231 // Two commits in the chain.
232 let head = repo.find_reference(&id.refname()).unwrap().target().unwrap();
233 let head_commit = repo.find_commit(head).unwrap();
234 assert_eq!(head_commit.parent_count(), 1);
235 let read_back = read(&repo, &id).unwrap().unwrap();
236 assert_eq!(read_back.content, "v2");
237 }
238
239 #[test]
240 fn update_idempotent_when_tree_unchanged() {
241 let dir = tempfile::tempdir().unwrap();
242 let repo = init_repo(dir.path());
243 let t = Task::new("same");
244 let id = create(&repo, &t, "create").unwrap();
245 let head1 = repo.find_reference(&id.refname()).unwrap().target().unwrap();
246 update(&repo, &id, &t, "noop").unwrap();
247 let head2 = repo.find_reference(&id.refname()).unwrap().target().unwrap();
248 assert_eq!(head1, head2);
249 }
250
251 #[test]
252 fn list_all_sees_every_task() {
253 let dir = tempfile::tempdir().unwrap();
254 let repo = init_repo(dir.path());
255 let a = create(&repo, &Task::new("a"), "c").unwrap();
256 let b = create(&repo, &Task::new("b"), "c").unwrap();
257 let mut got = list_all(&repo).unwrap();
258 got.sort();
259 let mut want = vec![a, b];
260 want.sort();
261 assert_eq!(got, want);
262 }
263
264 #[test]
265 fn stable_id_equals_blob_oid() {
266 let dir = tempfile::tempdir().unwrap();
267 let repo = init_repo(dir.path());
268 let id = create(&repo, &Task::new("xyz"), "c").unwrap();
269 let direct = repo.blob(b"xyz").unwrap();
270 assert_eq!(id.0, direct.to_string());
271 }
272}