A file-based task manager
0
fork

Configure Feed

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

at 92d69a30ff8e29caba866ea33aab70d2cee199cf 297 lines 9.5 kB view raw
1//! A queue: an ordered list of tasks plus an inbox of pending assignments. 2//! 3//! Stored as a commit chain at `refs/tsk/queues/<name>`. Tree layout: 4//! index → blob: ordered stable ids (one per line, top-of-stack first) 5//! can-pull → blob: "true" or "false" (defaults: true for `tsk`, false otherwise) 6//! inbox/<src>-<n> → blob: stable id of a task assigned by queue <src> 7//! 8//! Queues are pushed/shared (refspec `refs/tsk/*`). The active queue per-user 9//! is selected by `<git-dir>/tsk/queue`. 10 11use crate::errors::{Error, Result}; 12use crate::object::{self, StableId}; 13use git2::{Oid, Repository}; 14use std::collections::BTreeMap; 15 16pub const QUEUE_REF_PREFIX: &str = "refs/tsk/queues/"; 17pub const DEFAULT_QUEUE: &str = "tsk"; 18const INDEX_FILE: &str = "index"; 19const CAN_PULL_FILE: &str = "can-pull"; 20const INBOX_DIR: &str = "inbox"; 21 22pub fn refname(name: &str) -> String { 23 format!("{QUEUE_REF_PREFIX}{name}") 24} 25 26pub fn validate_name(name: &str) -> Result<()> { 27 if name.is_empty() { 28 return Err(Error::Parse("Queue name cannot be empty".into())); 29 } 30 if !name 31 .chars() 32 .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 33 { 34 return Err(Error::Parse(format!( 35 "Queue '{name}' must contain only alphanumerics, '-', or '_'" 36 ))); 37 } 38 Ok(()) 39} 40 41#[derive(Clone, Debug, Eq, PartialEq)] 42pub struct Queue { 43 /// top-of-stack first 44 pub index: Vec<StableId>, 45 pub can_pull: bool, 46 /// inbox key → stable id of the task being offered 47 pub inbox: BTreeMap<String, StableId>, 48} 49 50impl Queue { 51 pub fn new(name: &str) -> Self { 52 Self { 53 index: Vec::new(), 54 can_pull: name == DEFAULT_QUEUE, 55 inbox: BTreeMap::new(), 56 } 57 } 58} 59 60pub fn read(repo: &Repository, name: &str) -> Result<Queue> { 61 let Ok(r) = repo.find_reference(&refname(name)) else { 62 return Ok(Queue::new(name)); 63 }; 64 let Some(target) = r.target() else { 65 return Ok(Queue::new(name)); 66 }; 67 let tree = repo.find_commit(target)?.tree()?; 68 let mut q = Queue::new(name); 69 if let Some(e) = tree.get_name(INDEX_FILE) { 70 let blob = e.to_object(repo)?.peel_to_blob()?; 71 q.index = String::from_utf8_lossy(blob.content()) 72 .lines() 73 .filter(|l| !l.trim().is_empty()) 74 .map(|l| StableId(l.trim().to_string())) 75 .collect(); 76 } 77 if let Some(e) = tree.get_name(CAN_PULL_FILE) { 78 let blob = e.to_object(repo)?.peel_to_blob()?; 79 q.can_pull = String::from_utf8_lossy(blob.content()).trim() == "true"; 80 } 81 if let Some(e) = tree.get_name(INBOX_DIR) { 82 let inbox_tree = e.to_object(repo)?.peel_to_tree()?; 83 for ie in inbox_tree.iter() { 84 let Some(name) = ie.name() else { continue }; 85 let blob = ie.to_object(repo)?.peel_to_blob()?; 86 let stable = String::from_utf8_lossy(blob.content()).trim().to_string(); 87 if !stable.is_empty() { 88 q.inbox 89 .insert(name.to_string(), StableId(stable)); 90 } 91 } 92 } 93 Ok(q) 94} 95 96fn build_tree(repo: &Repository, q: &Queue) -> Result<Oid> { 97 let mut tb = repo.treebuilder(None)?; 98 let index_text: String = q 99 .index 100 .iter() 101 .map(|s| format!("{}\n", s.0)) 102 .collect(); 103 let index_oid = repo.blob(index_text.as_bytes())?; 104 tb.insert(INDEX_FILE, index_oid, 0o100644)?; 105 let cp = if q.can_pull { "true\n" } else { "false\n" }; 106 let cp_oid = repo.blob(cp.as_bytes())?; 107 tb.insert(CAN_PULL_FILE, cp_oid, 0o100644)?; 108 if !q.inbox.is_empty() { 109 let mut ib = repo.treebuilder(None)?; 110 for (k, v) in &q.inbox { 111 let oid = repo.blob(v.0.as_bytes())?; 112 ib.insert(k.as_str(), oid, 0o100644)?; 113 } 114 let ib_oid = ib.write()?; 115 tb.insert(INBOX_DIR, ib_oid, 0o040000)?; 116 } 117 Ok(tb.write()?) 118} 119 120pub fn write(repo: &Repository, name: &str, q: &Queue, message: &str) -> Result<()> { 121 validate_name(name)?; 122 let tree_oid = build_tree(repo, q)?; 123 let parent = repo 124 .find_reference(&refname(name)) 125 .ok() 126 .and_then(|r| r.target()) 127 .and_then(|o| repo.find_commit(o).ok()); 128 if let Some(p) = &parent 129 && p.tree_id() == tree_oid 130 { 131 return Ok(()); 132 } 133 let sig = object::signature(repo); 134 let parents: Vec<&git2::Commit> = parent.iter().collect(); 135 let commit = repo.commit( 136 None, 137 &sig, 138 &sig, 139 message, 140 &repo.find_tree(tree_oid)?, 141 &parents, 142 )?; 143 repo.reference(&refname(name), commit, true, message)?; 144 Ok(()) 145} 146 147pub fn list_names(repo: &Repository) -> Result<Vec<String>> { 148 let mut out = Vec::new(); 149 for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? { 150 let r = r?; 151 if let Some(name) = r.name() 152 && let Some(rest) = name.strip_prefix(QUEUE_REF_PREFIX) 153 { 154 out.push(rest.to_string()); 155 } 156 } 157 out.sort(); 158 Ok(out) 159} 160 161/// Push `stable` onto the top of `name`'s index. Idempotent: if already 162/// present, moves it to the top. 163pub fn push_top(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<()> { 164 let mut q = read(repo, name)?; 165 q.index.retain(|s| s != &stable); 166 q.index.insert(0, stable); 167 write(repo, name, &q, message) 168} 169 170pub fn push_bottom( 171 repo: &Repository, 172 name: &str, 173 stable: StableId, 174 message: &str, 175) -> Result<()> { 176 let mut q = read(repo, name)?; 177 q.index.retain(|s| s != &stable); 178 q.index.push(stable); 179 write(repo, name, &q, message) 180} 181 182pub fn remove(repo: &Repository, name: &str, stable: &StableId, message: &str) -> Result<bool> { 183 let mut q = read(repo, name)?; 184 let len = q.index.len(); 185 q.index.retain(|s| s != stable); 186 if q.index.len() == len { 187 return Ok(false); 188 } 189 write(repo, name, &q, message)?; 190 Ok(true) 191} 192 193/// Stable inbox key for a `(source-queue, sequence)` pair so re-assigns 194/// overwrite the same slot rather than piling up. Sequence is the per-source 195/// counter the caller maintains; we don't try to compute it here. 196pub fn inbox_key(src_queue: &str, src_seq: u32) -> String { 197 format!("{src_queue}-{src_seq}") 198} 199 200pub fn add_to_inbox( 201 repo: &Repository, 202 name: &str, 203 key: String, 204 stable: StableId, 205 message: &str, 206) -> Result<()> { 207 let mut q = read(repo, name)?; 208 q.inbox.insert(key, stable); 209 write(repo, name, &q, message) 210} 211 212pub fn take_from_inbox( 213 repo: &Repository, 214 name: &str, 215 key: &str, 216 message: &str, 217) -> Result<Option<StableId>> { 218 let mut q = read(repo, name)?; 219 let Some(stable) = q.inbox.remove(key) else { 220 return Ok(None); 221 }; 222 write(repo, name, &q, message)?; 223 Ok(Some(stable)) 224} 225 226#[cfg(test)] 227mod test { 228 use super::*; 229 use crate::object; 230 231 fn init_repo(p: &std::path::Path) -> Repository { 232 let r = Repository::init(p).unwrap(); 233 let mut cfg = r.config().unwrap(); 234 cfg.set_str("user.name", "T").unwrap(); 235 cfg.set_str("user.email", "t@e").unwrap(); 236 r 237 } 238 239 #[test] 240 fn empty_default_queue_is_can_pull() { 241 let dir = tempfile::tempdir().unwrap(); 242 let repo = init_repo(dir.path()); 243 let q = read(&repo, "tsk").unwrap(); 244 assert!(q.can_pull); 245 assert!(q.index.is_empty()); 246 } 247 248 #[test] 249 fn empty_named_queue_defaults_no_pull() { 250 let dir = tempfile::tempdir().unwrap(); 251 let repo = init_repo(dir.path()); 252 let q = read(&repo, "private").unwrap(); 253 assert!(!q.can_pull); 254 } 255 256 #[test] 257 fn push_pop_round_trip() { 258 let dir = tempfile::tempdir().unwrap(); 259 let repo = init_repo(dir.path()); 260 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 261 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap(); 262 push_top(&repo, "tsk", s1.clone(), "push").unwrap(); 263 push_top(&repo, "tsk", s2.clone(), "push").unwrap(); 264 let q = read(&repo, "tsk").unwrap(); 265 assert_eq!(q.index, vec![s2.clone(), s1.clone()]); 266 assert!(remove(&repo, "tsk", &s2, "drop").unwrap()); 267 let q = read(&repo, "tsk").unwrap(); 268 assert_eq!(q.index, vec![s1]); 269 } 270 271 #[test] 272 fn push_top_dedupes_existing() { 273 let dir = tempfile::tempdir().unwrap(); 274 let repo = init_repo(dir.path()); 275 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 276 push_top(&repo, "tsk", s.clone(), "push").unwrap(); 277 push_bottom(&repo, "tsk", s.clone(), "push").unwrap(); // same id, moved to bottom 278 let q = read(&repo, "tsk").unwrap(); 279 assert_eq!(q.index.len(), 1); 280 assert_eq!(q.index[0], s); 281 } 282 283 #[test] 284 fn inbox_round_trip() { 285 let dir = tempfile::tempdir().unwrap(); 286 let repo = init_repo(dir.path()); 287 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap(); 288 let key = inbox_key("alice", 5); 289 add_to_inbox(&repo, "bob", key.clone(), s.clone(), "assign").unwrap(); 290 let q = read(&repo, "bob").unwrap(); 291 assert_eq!(q.inbox.get(&key), Some(&s)); 292 let taken = take_from_inbox(&repo, "bob", &key, "accept").unwrap(); 293 assert_eq!(taken, Some(s)); 294 let q = read(&repo, "bob").unwrap(); 295 assert!(q.inbox.is_empty()); 296 } 297}