A file-based task manager
1//! A namespace: a tree mapping human-readable ids → task stable ids.
2//!
3//! Stored as a commit chain at `refs/tsk/namespaces/<name>`. Tree layout:
4//! next → blob: next human id to allocate, e.g. "5\n"
5//! ids/<human-id> → blob: 40-hex stable id
6//!
7//! The default namespace is `tsk`. Namespaces are self-contained (no
8//! cross-namespace references at this layer).
9
10use crate::errors::{Error, Result};
11use crate::object::{self, StableId};
12use git2::{Oid, Repository};
13use std::collections::BTreeMap;
14
15pub const NS_REF_PREFIX: &str = "refs/tsk/namespaces/";
16pub const DEFAULT_NS: &str = "tsk";
17const NEXT_FILE: &str = "next";
18const IDS_DIR: &str = "ids";
19
20pub fn refname(name: &str) -> String {
21 format!("{NS_REF_PREFIX}{name}")
22}
23
24pub fn validate_name(name: &str) -> Result<()> {
25 if name.is_empty() {
26 return Err(Error::Parse("Namespace name cannot be empty".into()));
27 }
28 if !name
29 .chars()
30 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
31 {
32 return Err(Error::Parse(format!(
33 "Namespace '{name}' must contain only alphanumerics, '-', or '_'"
34 )));
35 }
36 Ok(())
37}
38
39#[derive(Clone, Debug, Default, Eq, PartialEq)]
40pub struct Namespace {
41 pub next: u32,
42 /// human id → stable id
43 pub mapping: BTreeMap<u32, StableId>,
44}
45
46pub fn read(repo: &Repository, name: &str) -> Result<Namespace> {
47 let Ok(r) = repo.find_reference(&refname(name)) else {
48 return Ok(Namespace {
49 next: 1,
50 mapping: BTreeMap::new(),
51 });
52 };
53 let Some(target) = r.target() else {
54 return Ok(Namespace {
55 next: 1,
56 mapping: BTreeMap::new(),
57 });
58 };
59 read_at_commit(repo, target)
60}
61
62/// Read a namespace from the tree of a specific commit (rather than from
63/// the active ref). Used by the namespace merge driver to compare local
64/// and fetched-remote tips.
65pub fn read_at_commit(repo: &Repository, commit_oid: Oid) -> Result<Namespace> {
66 let tree = repo.find_commit(commit_oid)?.tree()?;
67 let mut ns = Namespace {
68 next: 1,
69 mapping: BTreeMap::new(),
70 };
71 if let Some(entry) = tree.get_name(NEXT_FILE) {
72 let blob = entry.to_object(repo)?.peel_to_blob()?;
73 ns.next = String::from_utf8_lossy(blob.content())
74 .trim()
75 .parse()
76 .unwrap_or(1);
77 }
78 if let Some(ids_entry) = tree.get_name(IDS_DIR) {
79 let ids_tree = ids_entry.to_object(repo)?.peel_to_tree()?;
80 for e in ids_tree.iter() {
81 let Some(name) = e.name() else { continue };
82 let Ok(human) = name.parse::<u32>() else {
83 continue;
84 };
85 let blob = e.to_object(repo)?.peel_to_blob()?;
86 let stable = String::from_utf8_lossy(blob.content()).trim().to_string();
87 if !stable.is_empty() {
88 ns.mapping.insert(human, StableId(stable));
89 }
90 }
91 }
92 Ok(ns)
93}
94
95pub fn build_tree(repo: &Repository, ns: &Namespace) -> Result<Oid> {
96 let mut ids_tb = repo.treebuilder(None)?;
97 for (human, stable) in &ns.mapping {
98 let oid = repo.blob(stable.0.as_bytes())?;
99 ids_tb.insert(human.to_string().as_str(), oid, 0o100644)?;
100 }
101 let ids_oid = ids_tb.write()?;
102
103 let mut tb = repo.treebuilder(None)?;
104 let next_oid = repo.blob(format!("{}\n", ns.next).as_bytes())?;
105 tb.insert(NEXT_FILE, next_oid, 0o100644)?;
106 tb.insert(IDS_DIR, ids_oid, 0o040000)?;
107 Ok(tb.write()?)
108}
109
110pub fn write(repo: &Repository, name: &str, ns: &Namespace, message: &str) -> Result<()> {
111 validate_name(name)?;
112 let tree_oid = build_tree(repo, ns)?;
113 let parent = repo
114 .find_reference(&refname(name))
115 .ok()
116 .and_then(|r| r.target())
117 .and_then(|o| repo.find_commit(o).ok());
118 if let Some(p) = &parent
119 && p.tree_id() == tree_oid
120 {
121 return Ok(());
122 }
123 let sig = object::signature(repo);
124 let parents: Vec<&git2::Commit> = parent.iter().collect();
125 let commit = repo.commit(
126 None,
127 &sig,
128 &sig,
129 message,
130 &repo.find_tree(tree_oid)?,
131 &parents,
132 )?;
133 repo.reference(&refname(name), commit, true, message)?;
134 Ok(())
135}
136
137/// Allocate the next human id, insert the binding, and persist. Returns the
138/// human id assigned.
139pub fn assign_id(
140 repo: &Repository,
141 name: &str,
142 stable: StableId,
143 message: &str,
144) -> Result<u32> {
145 let mut ns = read(repo, name)?;
146 let human = ns.next;
147 ns.next += 1;
148 ns.mapping.insert(human, stable);
149 write(repo, name, &ns, &format!("{message} {name}-{human}"))?;
150 Ok(human)
151}
152
153#[allow(dead_code)] // kept for future "tsk forget" / hard-delete command
154pub fn unassign_id(repo: &Repository, name: &str, human: u32, message: &str) -> Result<()> {
155 let mut ns = read(repo, name)?;
156 if ns.mapping.remove(&human).is_some() {
157 write(repo, name, &ns, &format!("{message} {name}-{human}"))?;
158 }
159 Ok(())
160}
161
162pub fn list_names(repo: &Repository) -> Result<Vec<String>> {
163 let mut out = Vec::new();
164 for r in repo.references_glob(&format!("{NS_REF_PREFIX}*"))? {
165 let r = r?;
166 if let Some(name) = r.name()
167 && let Some(rest) = name.strip_prefix(NS_REF_PREFIX)
168 {
169 out.push(rest.to_string());
170 }
171 }
172 out.sort();
173 Ok(out)
174}
175
176pub fn lookup(repo: &Repository, name: &str, human: u32) -> Result<Option<StableId>> {
177 Ok(read(repo, name)?.mapping.get(&human).cloned())
178}
179
180/// Existing human id for `stable` in `name`, or a freshly-assigned one.
181pub fn ensure_bound(
182 repo: &Repository,
183 name: &str,
184 stable: StableId,
185 message: &str,
186) -> Result<u32> {
187 match human_for(repo, name, &stable)? {
188 Some(h) => Ok(h),
189 None => assign_id(repo, name, stable, message),
190 }
191}
192
193/// Reverse lookup: stable → human in the given namespace, if present.
194pub fn human_for(repo: &Repository, name: &str, stable: &StableId) -> Result<Option<u32>> {
195 Ok(read(repo, name)?
196 .mapping
197 .iter()
198 .find(|(_, s)| *s == stable)
199 .map(|(h, _)| *h))
200}
201
202#[cfg(test)]
203mod test {
204 use super::*;
205 use crate::object;
206
207 fn init_repo(p: &std::path::Path) -> Repository {
208 let r = Repository::init(p).unwrap();
209 let mut cfg = r.config().unwrap();
210 cfg.set_str("user.name", "T").unwrap();
211 cfg.set_str("user.email", "t@e").unwrap();
212 r
213 }
214
215 #[test]
216 fn empty_namespace_reads_as_default() {
217 let dir = tempfile::tempdir().unwrap();
218 let repo = init_repo(dir.path());
219 let ns = read(&repo, "tsk").unwrap();
220 assert_eq!(ns.next, 1);
221 assert!(ns.mapping.is_empty());
222 }
223
224 #[test]
225 fn assign_then_round_trip() {
226 let dir = tempfile::tempdir().unwrap();
227 let repo = init_repo(dir.path());
228 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap();
229 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap();
230 let h1 = assign_id(&repo, "tsk", s1.clone(), "assign").unwrap();
231 let h2 = assign_id(&repo, "tsk", s2.clone(), "assign").unwrap();
232 assert_eq!(h1, 1);
233 assert_eq!(h2, 2);
234 let ns = read(&repo, "tsk").unwrap();
235 assert_eq!(ns.next, 3);
236 assert_eq!(ns.mapping.get(&1), Some(&s1));
237 assert_eq!(ns.mapping.get(&2), Some(&s2));
238 assert_eq!(human_for(&repo, "tsk", &s1).unwrap(), Some(1));
239 }
240
241 #[test]
242 fn unassign_removes_only_mapping_keeps_next() {
243 let dir = tempfile::tempdir().unwrap();
244 let repo = init_repo(dir.path());
245 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
246 let _ = assign_id(&repo, "tsk", s, "assign").unwrap();
247 unassign_id(&repo, "tsk", 1, "drop").unwrap();
248 let ns = read(&repo, "tsk").unwrap();
249 assert!(ns.mapping.is_empty());
250 assert_eq!(ns.next, 2, "next must monotonically grow");
251 }
252
253 #[test]
254 fn list_names_returns_known_namespaces() {
255 let dir = tempfile::tempdir().unwrap();
256 let repo = init_repo(dir.path());
257 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
258 let _ = assign_id(&repo, "tsk", s.clone(), "assign").unwrap();
259 let _ = assign_id(&repo, "alpha", s, "assign").unwrap();
260 let mut names = list_names(&repo).unwrap();
261 names.sort();
262 assert_eq!(names, vec!["alpha".to_string(), "tsk".to_string()]);
263 }
264
265 #[test]
266 fn validate_name_rejects_bad_input() {
267 assert!(validate_name("").is_err());
268 assert!(validate_name("a/b").is_err());
269 assert!(validate_name("ok-name_1").is_ok());
270 }
271}