A file-based task manager
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}