A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at b7f430fa061dff18bf0e0784b64de1809e1b2a2b 270 lines 8.9 kB view raw
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}