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