A file-based task manager
1//! Per-property indices: `refs/tsk/properties/<key>` → commit chain whose
2//! tree contains one blob per task that has the key set. Each blob's lines
3//! are that task's values for the key.
4//!
5//! Each key has its own commit history so concurrent edits to *different*
6//! keys can never conflict. Concurrent edits to the same key on different
7//! tasks land in different tree entries and can be merged by git's default
8//! tree merge; only same-key/same-task races require manual resolution.
9
10use crate::errors::Result;
11use crate::object::{self, StableId};
12use crate::propvalue;
13use git2::{Oid, Repository};
14use std::collections::BTreeMap;
15
16pub const PROP_REF_PREFIX: &str = "refs/tsk/properties/";
17
18pub fn refname(key: &str) -> String {
19 format!("{PROP_REF_PREFIX}{key}")
20}
21
22/// Read every (stable_id, values) entry currently indexed under `key`.
23pub fn read(repo: &Repository, key: &str) -> Result<BTreeMap<StableId, Vec<String>>> {
24 let mut out = BTreeMap::new();
25 let Ok(r) = repo.find_reference(&refname(key)) else {
26 return Ok(out);
27 };
28 let Some(target) = r.target() else {
29 return Ok(out);
30 };
31 let tree = repo.find_commit(target)?.tree()?;
32 for entry in tree.iter() {
33 let Some(name) = entry.name() else { continue };
34 let blob = entry.to_object(repo)?.peel_to_blob()?;
35 let values = propvalue::decode(blob.content());
36 out.insert(StableId(name.to_string()), values);
37 }
38 Ok(out)
39}
40
41fn write_index(
42 repo: &Repository,
43 key: &str,
44 entries: &BTreeMap<StableId, Vec<String>>,
45 message: &str,
46) -> Result<()> {
47 if entries.is_empty() {
48 // Drop the ref entirely — empty indexes are noise.
49 if let Ok(mut r) = repo.find_reference(&refname(key)) {
50 r.delete()?;
51 }
52 return Ok(());
53 }
54 let mut tb = repo.treebuilder(None)?;
55 for (stable, values) in entries {
56 let body = propvalue::encode(values);
57 let oid = repo.blob(&body)?;
58 tb.insert(stable.0.as_str(), oid, 0o100644)?;
59 }
60 let tree_oid: Oid = tb.write()?;
61 let parent = repo
62 .find_reference(&refname(key))
63 .ok()
64 .and_then(|r| r.target())
65 .and_then(|o| repo.find_commit(o).ok());
66 if let Some(p) = &parent
67 && p.tree_id() == tree_oid
68 {
69 return Ok(());
70 }
71 let sig = object::signature(repo);
72 let parents: Vec<&git2::Commit> = parent.iter().collect();
73 let commit = repo.commit(
74 None,
75 &sig,
76 &sig,
77 message,
78 &repo.find_tree(tree_oid)?,
79 &parents,
80 )?;
81 repo.reference(&refname(key), commit, true, message)?;
82 Ok(())
83}
84
85/// Set values for `(key, stable)` in the index; empty `values` removes the
86/// task from this key's index.
87pub fn set(
88 repo: &Repository,
89 key: &str,
90 stable: &StableId,
91 values: &[String],
92 message: &str,
93) -> Result<()> {
94 let mut entries = read(repo, key)?;
95 if values.is_empty() {
96 entries.remove(stable);
97 } else {
98 entries.insert(stable.clone(), values.to_vec());
99 }
100 write_index(repo, key, &entries, message)
101}
102
103/// Every property key currently indexed in this repo.
104pub fn list_keys(repo: &Repository) -> Result<Vec<String>> {
105 let mut out = Vec::new();
106 for r in repo.references_glob(&format!("{PROP_REF_PREFIX}*"))? {
107 let r = r?;
108 if let Some(name) = r.name()
109 && let Some(rest) = name.strip_prefix(PROP_REF_PREFIX)
110 {
111 out.push(rest.to_string());
112 }
113 }
114 out.sort();
115 Ok(out)
116}
117
118/// Stable ids of every task that has `key` set; if `value` is supplied,
119/// restricts to tasks where `key` contains that value.
120pub fn find(repo: &Repository, key: &str, value: Option<&str>) -> Result<Vec<StableId>> {
121 let entries = read(repo, key)?;
122 Ok(entries
123 .into_iter()
124 .filter(|(_, vs)| value.map_or(true, |target| vs.iter().any(|v| v == target)))
125 .map(|(s, _)| s)
126 .collect())
127}
128
129/// Distinct values seen for `key`, sorted alphabetically.
130pub fn values_for(repo: &Repository, key: &str) -> Result<Vec<String>> {
131 let entries = read(repo, key)?;
132 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
133 for vs in entries.values() {
134 for v in vs {
135 set.insert(v.clone());
136 }
137 }
138 Ok(set.into_iter().collect())
139}
140
141/// Re-index a task across all currently-indexed keys + its own properties.
142/// Removes the task from indices it no longer belongs to. Call this after
143/// any task tree write that may have added/removed properties.
144pub fn reindex_task(
145 repo: &Repository,
146 stable: &StableId,
147 properties: &BTreeMap<String, Vec<String>>,
148) -> Result<()> {
149 use std::collections::BTreeSet;
150 let known: BTreeSet<String> = list_keys(repo)?.into_iter().collect();
151 let current: BTreeSet<String> = properties.keys().cloned().collect();
152 // Update keys the task currently has.
153 for key in ¤t {
154 let values = properties.get(key).cloned().unwrap_or_default();
155 set(repo, key, stable, &values, "reindex")?;
156 }
157 // Drop the task from indices it no longer participates in.
158 for key in known.difference(¤t) {
159 set(repo, key, stable, &[], "reindex-remove")?;
160 }
161 Ok(())
162}
163
164#[cfg(test)]
165mod test {
166 use super::*;
167 use crate::object;
168
169 fn init_repo(p: &std::path::Path) -> Repository {
170 let r = Repository::init(p).unwrap();
171 let mut cfg = r.config().unwrap();
172 cfg.set_str("user.name", "T").unwrap();
173 cfg.set_str("user.email", "t@e").unwrap();
174 r
175 }
176
177 #[test]
178 fn set_find_round_trip() {
179 let dir = tempfile::tempdir().unwrap();
180 let repo = init_repo(dir.path());
181 let s1 = object::create(&repo, &object::Task::new("a"), "c").unwrap();
182 let s2 = object::create(&repo, &object::Task::new("b"), "c").unwrap();
183 set(&repo, "priority", &s1, &["high".into()], "x").unwrap();
184 set(&repo, "priority", &s2, &["low".into(), "medium".into()], "x").unwrap();
185 let high = find(&repo, "priority", Some("high")).unwrap();
186 assert_eq!(high, vec![s1.clone()]);
187 let any_priority = find(&repo, "priority", None).unwrap();
188 assert_eq!(any_priority.len(), 2);
189 assert_eq!(
190 values_for(&repo, "priority").unwrap(),
191 vec!["high".to_string(), "low".into(), "medium".into()]
192 );
193 }
194
195 #[test]
196 fn empty_values_removes_from_index() {
197 let dir = tempfile::tempdir().unwrap();
198 let repo = init_repo(dir.path());
199 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
200 set(&repo, "tag", &s, &["x".into()], "x").unwrap();
201 assert_eq!(find(&repo, "tag", None).unwrap(), vec![s.clone()]);
202 set(&repo, "tag", &s, &[], "rm").unwrap();
203 assert_eq!(find(&repo, "tag", None).unwrap(), Vec::<StableId>::new());
204 // Index ref is dropped when the last entry is removed.
205 assert!(repo.find_reference(&refname("tag")).is_err());
206 }
207
208 #[test]
209 fn list_keys_reports_indexed() {
210 let dir = tempfile::tempdir().unwrap();
211 let repo = init_repo(dir.path());
212 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
213 set(&repo, "k1", &s, &["v".into()], "x").unwrap();
214 set(&repo, "k2", &s, &["v".into()], "x").unwrap();
215 let mut keys = list_keys(&repo).unwrap();
216 keys.sort();
217 assert_eq!(keys, vec!["k1".to_string(), "k2".into()]);
218 }
219
220 #[test]
221 fn reindex_drops_removed_keys() {
222 let dir = tempfile::tempdir().unwrap();
223 let repo = init_repo(dir.path());
224 let s = object::create(&repo, &object::Task::new("a"), "c").unwrap();
225 set(&repo, "kept", &s, &["v".into()], "x").unwrap();
226 set(&repo, "gone", &s, &["v".into()], "x").unwrap();
227
228 let mut props: BTreeMap<String, Vec<String>> = BTreeMap::new();
229 props.insert("kept".into(), vec!["v".into()]);
230 // "gone" is not in props anymore; reindex should remove the task from it.
231 reindex_task(&repo, &s, &props).unwrap();
232 assert!(find(&repo, "gone", None).unwrap().is_empty());
233 assert_eq!(find(&repo, "kept", None).unwrap(), vec![s]);
234 }
235}