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 Some(target) = repo.find_reference(&refname(name)).ok().and_then(|r| r.target()) else {
62 return Ok(Queue::new(name));
63 };
64 read_at_commit(repo, name, target)
65}
66
67/// Read a queue from the tree of a specific commit (used by the queue
68/// merge driver to compare local and fetched-remote tips).
69pub fn read_at_commit(repo: &Repository, name: &str, commit_oid: Oid) -> Result<Queue> {
70 let tree = repo.find_commit(commit_oid)?.tree()?;
71 let mut q = Queue::new(name);
72 if let Some(e) = tree.get_name(INDEX_FILE) {
73 let blob = e.to_object(repo)?.peel_to_blob()?;
74 q.index = String::from_utf8_lossy(blob.content())
75 .lines()
76 .filter(|l| !l.trim().is_empty())
77 .map(|l| StableId(l.trim().to_string()))
78 .collect();
79 }
80 if let Some(e) = tree.get_name(CAN_PULL_FILE) {
81 let blob = e.to_object(repo)?.peel_to_blob()?;
82 q.can_pull = String::from_utf8_lossy(blob.content()).trim() == "true";
83 }
84 if let Some(e) = tree.get_name(INBOX_DIR) {
85 let inbox_tree = e.to_object(repo)?.peel_to_tree()?;
86 for ie in inbox_tree.iter() {
87 let Some(name) = ie.name() else { continue };
88 let blob = ie.to_object(repo)?.peel_to_blob()?;
89 let stable = String::from_utf8_lossy(blob.content()).trim().to_string();
90 if !stable.is_empty() {
91 q.inbox.insert(name.to_string(), StableId(stable));
92 }
93 }
94 }
95 Ok(q)
96}
97
98pub fn build_tree(repo: &Repository, q: &Queue) -> Result<Oid> {
99 let mut tb = repo.treebuilder(None)?;
100 let index_text: String = q
101 .index
102 .iter()
103 .map(|s| format!("{}\n", s.0))
104 .collect();
105 let index_oid = repo.blob(index_text.as_bytes())?;
106 tb.insert(INDEX_FILE, index_oid, 0o100644)?;
107 let cp = if q.can_pull { "true\n" } else { "false\n" };
108 let cp_oid = repo.blob(cp.as_bytes())?;
109 tb.insert(CAN_PULL_FILE, cp_oid, 0o100644)?;
110 if !q.inbox.is_empty() {
111 let mut ib = repo.treebuilder(None)?;
112 for (k, v) in &q.inbox {
113 let oid = repo.blob(v.0.as_bytes())?;
114 ib.insert(k.as_str(), oid, 0o100644)?;
115 }
116 let ib_oid = ib.write()?;
117 tb.insert(INBOX_DIR, ib_oid, 0o040000)?;
118 }
119 Ok(tb.write()?)
120}
121
122pub fn write(repo: &Repository, name: &str, q: &Queue, message: &str) -> Result<()> {
123 validate_name(name)?;
124 let tree_oid = build_tree(repo, q)?;
125 let parent = repo
126 .find_reference(&refname(name))
127 .ok()
128 .and_then(|r| r.target())
129 .and_then(|o| repo.find_commit(o).ok());
130 if let Some(p) = &parent
131 && p.tree_id() == tree_oid
132 {
133 return Ok(());
134 }
135 let sig = object::signature(repo);
136 let parents: Vec<&git2::Commit> = parent.iter().collect();
137 let commit = repo.commit(
138 None,
139 &sig,
140 &sig,
141 message,
142 &repo.find_tree(tree_oid)?,
143 &parents,
144 )?;
145 repo.reference(&refname(name), commit, true, message)?;
146 Ok(())
147}
148
149pub fn list_names(repo: &Repository) -> Result<Vec<String>> {
150 let mut out = Vec::new();
151 for r in repo.references_glob(&format!("{QUEUE_REF_PREFIX}*"))? {
152 let r = r?;
153 if let Some(name) = r.name()
154 && let Some(rest) = name.strip_prefix(QUEUE_REF_PREFIX)
155 {
156 out.push(rest.to_string());
157 }
158 }
159 out.sort();
160 Ok(out)
161}
162
163/// Push `stable` onto the top of `name`'s index. Idempotent: if already
164/// present, moves it to the top.
165pub fn push_top(repo: &Repository, name: &str, stable: StableId, message: &str) -> Result<()> {
166 let mut q = read(repo, name)?;
167 q.index.retain(|s| s != &stable);
168 q.index.insert(0, stable);
169 write(repo, name, &q, message)
170}
171
172pub fn push_bottom(
173 repo: &Repository,
174 name: &str,
175 stable: StableId,
176 message: &str,
177) -> Result<()> {
178 let mut q = read(repo, name)?;
179 q.index.retain(|s| s != &stable);
180 q.index.push(stable);
181 write(repo, name, &q, message)
182}
183
184pub fn remove(repo: &Repository, name: &str, stable: &StableId, message: &str) -> Result<bool> {
185 let mut q = read(repo, name)?;
186 let len = q.index.len();
187 q.index.retain(|s| s != stable);
188 if q.index.len() == len {
189 return Ok(false);
190 }
191 write(repo, name, &q, message)?;
192 Ok(true)
193}
194
195/// Stable inbox key for a `(source-queue, sequence)` pair so re-assigns
196/// overwrite the same slot rather than piling up. Sequence is the per-source
197/// counter the caller maintains; we don't try to compute it here.
198pub fn inbox_key(src_queue: &str, src_seq: u32) -> String {
199 format!("{src_queue}-{src_seq}")
200}
201
202pub fn add_to_inbox(
203 repo: &Repository,
204 name: &str,
205 key: String,
206 stable: StableId,
207 message: &str,
208) -> Result<()> {
209 let mut q = read(repo, name)?;
210 q.inbox.insert(key, stable);
211 write(repo, name, &q, message)
212}
213
214pub fn take_from_inbox(
215 repo: &Repository,
216 name: &str,
217 key: &str,
218 message: &str,
219) -> Result<Option<StableId>> {
220 let mut q = read(repo, name)?;
221 let Some(stable) = q.inbox.remove(key) else {
222 return Ok(None);
223 };
224 write(repo, name, &q, message)?;
225 Ok(Some(stable))
226}
227
228#[cfg(test)]
229mod test {
230 use super::*;
231 use crate::object;
232
233 fn init_repo(p: &std::path::Path) -> Repository {
234 let r = Repository::init(p).unwrap();
235 let mut cfg = r.config().unwrap();
236 cfg.set_str("user.name", "T").unwrap();
237 cfg.set_str("user.email", "t@e").unwrap();
238 r
239 }
240
241 #[test]
242 fn empty_default_queue_is_can_pull() {
243 let dir = tempfile::tempdir().unwrap();
244 let repo = init_repo(dir.path());
245 let q = read(&repo, "tsk").unwrap();
246 assert!(q.can_pull);
247 assert!(q.index.is_empty());
248 }
249
250 #[test]
251 fn empty_named_queue_defaults_no_pull() {
252 let dir = tempfile::tempdir().unwrap();
253 let repo = init_repo(dir.path());
254 let q = read(&repo, "private").unwrap();
255 assert!(!q.can_pull);
256 }
257
258 #[test]
259 fn push_pop_round_trip() {
260 let dir = tempfile::tempdir().unwrap();
261 let repo = init_repo(dir.path());
262 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap();
263 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap();
264 push_top(&repo, "tsk", s1.clone(), "push").unwrap();
265 push_top(&repo, "tsk", s2.clone(), "push").unwrap();
266 let q = read(&repo, "tsk").unwrap();
267 assert_eq!(q.index, vec![s2.clone(), s1.clone()]);
268 assert!(remove(&repo, "tsk", &s2, "drop").unwrap());
269 let q = read(&repo, "tsk").unwrap();
270 assert_eq!(q.index, vec![s1]);
271 }
272
273 #[test]
274 fn push_top_dedupes_existing() {
275 let dir = tempfile::tempdir().unwrap();
276 let repo = init_repo(dir.path());
277 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
278 push_top(&repo, "tsk", s.clone(), "push").unwrap();
279 push_bottom(&repo, "tsk", s.clone(), "push").unwrap(); // same id, moved to bottom
280 let q = read(&repo, "tsk").unwrap();
281 assert_eq!(q.index.len(), 1);
282 assert_eq!(q.index[0], s);
283 }
284
285 #[test]
286 fn inbox_round_trip() {
287 let dir = tempfile::tempdir().unwrap();
288 let repo = init_repo(dir.path());
289 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
290 let key = inbox_key("alice", 5);
291 add_to_inbox(&repo, "bob", key.clone(), s.clone(), "assign").unwrap();
292 let q = read(&repo, "bob").unwrap();
293 assert_eq!(q.inbox.get(&key), Some(&s));
294 let taken = take_from_inbox(&repo, "bob", &key, "accept").unwrap();
295 assert_eq!(taken, Some(s));
296 let q = read(&repo, "bob").unwrap();
297 assert!(q.inbox.is_empty());
298 }
299}