//! Storage backends for tsk workspaces. //! //! A [`Store`] is a logical key/value blob store. Two impls are provided: //! //! - [`FileStore`] keeps blobs as files under `.tsk/`. Used when `tsk init` runs //! outside a git repository. //! - [`GitStore`] stores each blob as a git blob, addressed by a ref under //! `refs/tsk/`. Used when `tsk init` runs inside a git repository — the git //! refs are the only durable storage; nothing is cached on disk. //! //! Higher-level operations (tasks, attrs, backlinks, index, remotes) are //! implemented as free functions over `dyn Store` so both backends share a //! single implementation. use crate::errors::{Error, Result}; use crate::workspace::{Id, Remote}; use git2::{Reference, Repository}; use std::collections::{BTreeMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::str::FromStr; pub const GIT_BACKED_MARKER: &str = "git-backed"; pub const NAMESPACE_FILE: &str = "namespace"; pub const DEFAULT_NAMESPACE: &str = "default"; const REF_ROOT: &str = "refs/tsk"; /// A logical blob store. Keys are forward-slash separated strings. pub trait Store: Send + Sync { fn read(&self, key: &str) -> Result>>; fn write(&self, key: &str, data: &[u8]) -> Result<()> { self.write_with_meta(key, data, "write", None) } /// Same as [`write`] but with a structured event/detail attached. For /// the commit-backed git store this becomes the commit message; for the /// file store it's discarded. fn write_with_meta( &self, key: &str, data: &[u8], event: &str, detail: Option<&str>, ) -> Result<()>; fn delete(&self, key: &str) -> Result<()>; fn exists(&self, key: &str) -> Result; /// List all keys with the given prefix (no trailing slash). Returns full keys. fn list(&self, prefix: &str) -> Result>; } /// Format a commit message for a write to `key`. Single function so the layout /// is easy to change later (subject + structured trailer for now). pub fn format_commit_message(key: &str, event: &str, detail: Option<&str>) -> String { let subject = match detail { Some(d) => format!("tsk({key}): {event} {d}"), None => format!("tsk({key}): {event}"), }; format!( "{subject}\n\n# tsk-meta\nkey: {key}\nevent: {event}\ndetail: {}\n", detail.unwrap_or("") ) } /// `inbox/-` blobs are intentionally not commit-backed — they /// are super transient (deleted on accept) and have no useful history. fn is_blob_only_key(key: &str) -> bool { key.starts_with("inbox/") } // ─── FileStore ────────────────────────────────────────────────────────────── pub struct FileStore { pub root: PathBuf, } impl FileStore { pub fn new(root: PathBuf) -> Self { Self { root } } fn path(&self, key: &str) -> PathBuf { self.root.join(key) } } impl Store for FileStore { fn read(&self, key: &str) -> Result>> { let p = self.path(key); match fs::read(&p) { Ok(data) => Ok(Some(data)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } fn write_with_meta( &self, key: &str, data: &[u8], _event: &str, _detail: Option<&str>, ) -> Result<()> { // Meta is discarded; the file backend has no commit history to attach to. let p = self.path(key); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; } let tmp = p.with_extension("tmp"); let mut f = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(&tmp)?; f.write_all(data)?; f.sync_all()?; fs::rename(&tmp, &p)?; Ok(()) } fn delete(&self, key: &str) -> Result<()> { match fs::remove_file(self.path(key)) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e.into()), } } fn exists(&self, key: &str) -> Result { Ok(self.path(key).exists()) } fn list(&self, prefix: &str) -> Result> { let dir = self.path(prefix); if !dir.is_dir() { return Ok(Vec::new()); } let mut out = Vec::new(); for entry in fs::read_dir(&dir)? { let entry = entry?; if entry.file_type()?.is_file() && let Some(name) = entry.file_name().to_str() { out.push(format!("{prefix}/{name}")); } } Ok(out) } } // ─── GitStore ─────────────────────────────────────────────────────────────── pub struct GitStore { git_dir: PathBuf, namespace: String, } impl GitStore { pub fn open(git_dir: PathBuf) -> Result { Self::open_namespace(git_dir, DEFAULT_NAMESPACE.to_string()) } pub fn open_namespace(git_dir: PathBuf, namespace: String) -> Result { Repository::open(&git_dir)?; Ok(Self { git_dir, namespace }) } fn repo(&self) -> Result { Ok(Repository::open(&self.git_dir)?) } /// Prefix every namespace's refs share, e.g. `refs/tsk/`. fn ns_prefix(&self) -> String { format!("{REF_ROOT}/{}", self.namespace) } fn refname(&self, key: &str) -> String { format!("{}/{}", self.ns_prefix(), key) } /// Names of every ref starting with the given prefix. fn refs_starting_with(&self, prefix: &str) -> Result> { Ok(self .repo()? .references()? .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) .filter(|n| n.starts_with(prefix)) .collect()) } /// Number of refs currently under this store's namespace. pub fn namespace_ref_count(&self) -> Result { Ok(self .refs_starting_with(&format!("{}/", self.ns_prefix()))? .len()) } /// Delete every ref under this store's namespace. Returns the count. pub fn delete_namespace_refs(&self) -> Result { let repo = self.repo()?; let names = self.refs_starting_with(&format!("{}/", self.ns_prefix()))?; let count = names.len(); for n in names { if let Some(mut r) = try_ref(&repo, &n)? { r.delete()?; } } Ok(count) } /// List the namespaces present in this repo (any directory under refs/tsk/ /// containing at least one ref). pub fn list_namespaces(&self) -> Result> { let strip = format!("{REF_ROOT}/"); let mut out: std::collections::BTreeSet = std::collections::BTreeSet::new(); for name in self.refs_starting_with(&strip)? { if let Some(rest) = name.strip_prefix(&strip) && let Some((ns, _)) = rest.split_once('/') { out.insert(ns.to_string()); } } Ok(out.into_iter().collect()) } } /// `find_reference` translating NotFound to None. fn try_ref<'r>(repo: &'r Repository, name: &str) -> Result>> { match repo.find_reference(name) { Ok(r) => Ok(Some(r)), Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Read the underlying content blob given a ref's target OID. Handles both /// commit-backed refs (peel commit→tree→content file) and legacy blob refs /// (return the blob directly), so we keep working through migrations. pub fn read_blob_at(repo: &Repository, oid: git2::Oid) -> Result> { if let Ok(commit) = repo.find_commit(oid) { let tree = commit.tree()?; let entry = tree .get_name("content") .ok_or_else(|| Error::Parse("commit tree missing 'content'".into()))?; let blob = entry.to_object(repo)?.peel_to_blob()?; return Ok(blob.content().to_vec()); } let blob = repo.find_blob(oid)?; Ok(blob.content().to_vec()) } /// Author signature for tsk-generated commits. Falls back to a tsk-specific /// signature if the user hasn't configured `user.name` / `user.email`. fn git_signature(repo: &Repository) -> Result> { if let Ok(s) = repo.signature() { return Ok(s.to_owned()); } Ok(git2::Signature::now("tsk", "tsk@local")?) } /// One-shot migration: convert every blob-backed ref under `refs/tsk//...` /// to a single-commit chain, except for inbox/* (intentionally blob-backed). /// Idempotent — already-commit refs are skipped. pub fn migrate_to_commit_history(git_dir: &Path) -> Result { let repo = Repository::open(git_dir)?; let prefix = format!("{REF_ROOT}/"); let mut converted = 0usize; let names: Vec = repo .references()? .filter_map(|r| r.ok().and_then(|r| r.name().map(str::to_string))) .filter(|n| n.starts_with(&prefix)) .collect(); let sig = git_signature(&repo)?; for refname in names { let Some(rest) = refname.strip_prefix(&prefix) else { continue; }; let Some((_ns, key)) = rest.split_once('/') else { continue; }; if is_blob_only_key(key) { continue; } let r = repo.find_reference(&refname)?; let Some(target) = r.target() else { continue }; // Already a commit? skip. if repo.find_commit(target).is_ok() { continue; } // Build a one-file tree wrapping the existing blob and commit it. let mut tb = repo.treebuilder(None)?; tb.insert("content", target, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; let msg = format_commit_message(key, "migrated", None); let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &[])?; repo.reference(&refname, commit_oid, true, &msg)?; converted += 1; } Ok(converted) } impl Store for GitStore { fn read(&self, key: &str) -> Result>> { let repo = self.repo()?; let Some(r) = try_ref(&repo, &self.refname(key))? else { return Ok(None); }; let Some(target) = r.target() else { return Ok(None); }; Ok(Some(read_blob_at(&repo, target)?)) } fn write_with_meta( &self, key: &str, data: &[u8], event: &str, detail: Option<&str>, ) -> Result<()> { let repo = self.repo()?; let refname = self.refname(key); if is_blob_only_key(key) { let oid = repo.blob(data)?; repo.reference(&refname, oid, true, "tsk write")?; return Ok(()); } // Build a one-file tree {content: } and commit it onto the // existing chain (if any) for this ref. let blob_oid = repo.blob(data)?; let mut tb = repo.treebuilder(None)?; tb.insert("content", blob_oid, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; let sig = git_signature(&repo)?; let parent_commit = match try_ref(&repo, &refname)? { Some(r) => r.target().and_then(|oid| repo.find_commit(oid).ok()), None => None, }; // If the parent's tree already matches, skip — don't pile up no-op commits. if let Some(parent) = &parent_commit && parent.tree_id() == tree_oid { return Ok(()); } let parents: Vec<&git2::Commit> = parent_commit.iter().collect(); let msg = format_commit_message(key, event, detail); let commit_oid = repo.commit(None, &sig, &sig, &msg, &tree, &parents)?; repo.reference(&refname, commit_oid, true, &msg)?; Ok(()) } fn delete(&self, key: &str) -> Result<()> { let repo = self.repo()?; if let Some(mut r) = try_ref(&repo, &self.refname(key))? { r.delete()?; } Ok(()) } fn exists(&self, key: &str) -> Result { Ok(try_ref(&self.repo()?, &self.refname(key))?.is_some()) } fn list(&self, prefix: &str) -> Result> { let repo = self.repo()?; let strip = format!("{}/", self.ns_prefix()); repo.references_glob(&format!("{}/{}/*", self.ns_prefix(), prefix))? .filter_map(|r| { r.ok() .and_then(|r| { r.name() .and_then(|n| n.strip_prefix(&strip)) .map(str::to_string) }) .map(Ok) }) .collect() } } // ─── High-level operations over any Store ─────────────────────────────────── pub fn next_id(store: &dyn Store) -> Result { let cur = store .read("next")? .map(|b| String::from_utf8_lossy(&b).trim().to_string()) .unwrap_or_else(|| "1".to_string()); let id: u32 = cur.parse().unwrap_or(1); store.write("next", format!("{}\n", id + 1).as_bytes())?; Ok(Id(id)) } fn task_key(id: Id, archived: bool) -> String { let bucket = if archived { "archive" } else { "tasks" }; format!("{bucket}/{}", id.0) } #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum Loc { Active, Archived, } pub fn task_location(store: &dyn Store, id: Id) -> Result> { if store.exists(&task_key(id, false))? { Ok(Some(Loc::Active)) } else if store.exists(&task_key(id, true))? { Ok(Some(Loc::Archived)) } else { Ok(None) } } pub fn read_task(store: &dyn Store, id: Id) -> Result> { for (loc, archived) in [(Loc::Active, false), (Loc::Archived, true)] { if let Some(data) = store.read(&task_key(id, archived))? { let text = String::from_utf8_lossy(&data); let mut parts = text.splitn(2, '\n'); let title = parts.next().unwrap_or("").trim().to_string(); let body = parts.next().unwrap_or("").trim().to_string(); return Ok(Some((title, body, loc))); } } Ok(None) } pub fn write_task( store: &dyn Store, id: Id, title: &str, body: &str, loc: Loc, event: &str, detail: Option<&str>, ) -> Result<()> { let payload = format!("{}\n\n{}", title.trim(), body.trim()); store.write_with_meta( &task_key(id, loc == Loc::Archived), payload.as_bytes(), event, detail, ) } pub fn move_task(store: &dyn Store, id: Id, to: Loc) -> Result<()> { let from_archived = to == Loc::Active; let from_key = task_key(id, from_archived); let to_key = task_key(id, to == Loc::Archived); if from_key == to_key { return Ok(()); } let data = store .read(&from_key)? .ok_or_else(|| Error::Parse(format!("task {id} not present at {from_key}")))?; store.write(&to_key, &data)?; store.delete(&from_key)?; Ok(()) } pub fn list_active(store: &dyn Store) -> Result> { list_bucket(store, "tasks") } pub fn list_archive(store: &dyn Store) -> Result> { list_bucket(store, "archive") } fn list_bucket(store: &dyn Store, bucket: &str) -> Result> { let prefix = format!("{bucket}/"); let mut ids: Vec = store .list(bucket)? .iter() .filter_map(|k| { k.strip_prefix(&prefix)? .trim_end_matches(".tsk") .parse() .ok() .map(Id) }) .collect(); ids.sort_by_key(|i| i.0); Ok(ids) } /// Read+lossy-decode a blob, returning empty string when absent. pub fn read_text_blob(store: &dyn Store, key: &str) -> Result { read_text(store, key) } fn read_text(store: &dyn Store, key: &str) -> Result { Ok(store .read(key)? .map(|b| String::from_utf8_lossy(&b).into_owned()) .unwrap_or_default()) } /// Write `body` to `key`, or delete the blob if `body` is empty. fn write_or_delete(store: &dyn Store, key: &str, body: &str) -> Result<()> { if body.is_empty() { store.delete(key) } else { store.write(key, body.as_bytes()) } } pub fn read_attrs(store: &dyn Store, id: Id) -> Result> { Ok(read_text(store, &format!("attrs/{}", id.0))? .lines() .filter_map(|l| { l.split_once('\t') .map(|(k, v)| (k.to_string(), v.to_string())) }) .collect()) } pub fn write_attrs( store: &dyn Store, id: Id, attrs: &BTreeMap, event: &str, detail: Option<&str>, ) -> Result<()> { let body = attrs .iter() .map(|(k, v)| format!("{k}\t{v}\n")) .collect::(); let key = format!("attrs/{}", id.0); if body.is_empty() { return store.delete(&key); } store.write_with_meta(&key, body.as_bytes(), event, detail) } pub fn read_backlinks(store: &dyn Store, id: Id) -> Result> { Ok(read_text(store, &format!("backlinks/{}", id.0))? .split(',') .filter_map(|t| Id::from_str(t.trim()).ok()) .collect()) } pub fn write_backlinks(store: &dyn Store, id: Id, links: &HashSet) -> Result<()> { write_or_delete( store, &format!("backlinks/{}", id.0), &itertools::join(links, ","), ) } /// One line of a task's edit log. Lines are tab-separated: /// `\t\t\t` — empty fields allowed. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LogEntry { pub id: Id, pub timestamp: u64, pub event: String, pub detail: String, pub author: String, } impl LogEntry { fn parse(id: Id, line: &str) -> Option { let mut p = line.splitn(4, '\t'); let timestamp = p.next()?.parse().ok()?; Some(Self { id, timestamp, event: p.next().unwrap_or("").to_string(), detail: p.next().unwrap_or("").to_string(), author: p.next().unwrap_or("").to_string(), }) } } /// Append a single log entry for a task. The blob `log/` grows over time; /// it is never rewritten. pub fn append_log( store: &dyn Store, id: Id, event: &str, detail: Option<&str>, author: &str, ) -> Result<()> { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let line = format!("{ts}\t{event}\t{}\t{author}\n", detail.unwrap_or("")); let key = format!("log/{}", id.0); let existing = read_text(store, &key)?; store.write(&key, format!("{existing}{line}").as_bytes()) } pub fn read_log(store: &dyn Store, id: Id) -> Result> { Ok(read_text(store, &format!("log/{}", id.0))? .lines() .filter_map(|l| LogEntry::parse(id, l)) .collect()) } /// Serialized representation of a task pending in another namespace's inbox. /// /// On-blob format (inbox/): /// ```text /// source\t\t /// attr\t\t /// attr\t\t /// --- /// /// /// <body> /// ``` pub struct InboxPayload { pub source_namespace: String, pub source_id: u32, pub title: String, pub body: String, pub attrs: BTreeMap<String, String>, } impl InboxPayload { pub fn serialize(&self) -> String { let mut s = format!("source\t{}\t{}\n", self.source_namespace, self.source_id); for (k, v) in &self.attrs { s.push_str(&format!("attr\t{k}\t{v}\n")); } s.push_str("---\n"); s.push_str(&format!("{}\n\n{}", self.title.trim(), self.body.trim())); s } pub fn parse(text: &str) -> Result<Self> { let mut lines = text.lines(); let mut source_namespace = String::new(); let mut source_id: u32 = 0; let mut attrs = BTreeMap::new(); for line in &mut lines { if line == "---" { break; } let parts: Vec<&str> = line.splitn(3, '\t').collect(); match parts.as_slice() { ["source", ns, id] => { source_namespace = ns.to_string(); source_id = id .parse() .map_err(|_| Error::Parse(format!("invalid inbox source id: {id}")))?; } ["attr", k, v] => { attrs.insert(k.to_string(), v.to_string()); } _ => {} } } let rest: String = lines.collect::<Vec<_>>().join("\n"); let mut split = rest.splitn(2, "\n\n"); let title = split.next().unwrap_or("").trim().to_string(); let body = split.next().unwrap_or("").trim().to_string(); Ok(Self { source_namespace, source_id, title, body, attrs, }) } } /// Stable inbox key for a (namespace, source-id) pair so re-exports overwrite /// the same slot rather than piling up. pub fn inbox_key(src_namespace: &str, src_id: u32) -> String { format!("inbox/{src_namespace}-{src_id}") } /// Read every per-task log in the workspace and merge into a single feed /// sorted by timestamp ascending. pub fn read_all_logs(store: &dyn Store) -> Result<Vec<LogEntry>> { let mut all = Vec::new(); for key in store.list("log")? { if let Some(idstr) = key.strip_prefix("log/") && let Ok(n) = idstr.parse::<u32>() { all.extend(read_log(store, Id(n))?); } } all.sort_by_key(|e| e.timestamp); Ok(all) } pub fn read_remotes(store: &dyn Store) -> Result<Vec<Remote>> { Ok(read_text(store, "remotes")? .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .filter_map(|l| l.split_once('\t')) .map(|(p, path)| Remote { prefix: p.trim().into(), path: PathBuf::from(path.trim()), }) .collect()) } pub fn write_remotes(store: &dyn Store, remotes: &[Remote]) -> Result<()> { let body: String = remotes .iter() .map(|r| format!("{}\t{}\n", r.prefix, r.path.display())) .collect(); write_or_delete(store, "remotes", &body) } // ─── Detection / construction ────────────────────────────────────────────── pub fn detect_git_dir(start: &Path) -> Option<PathBuf> { crate::util::find_parent_with_dir(start.to_path_buf(), ".git") .ok() .flatten() } pub fn read_namespace(tsk_dir: &Path) -> String { fs::read_to_string(tsk_dir.join(NAMESPACE_FILE)) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| DEFAULT_NAMESPACE.to_string()) } pub fn write_namespace(tsk_dir: &Path, namespace: &str) -> Result<()> { fs::write(tsk_dir.join(NAMESPACE_FILE), namespace.as_bytes())?; Ok(()) } pub fn store_for(tsk_dir: &Path) -> Result<Box<dyn Store>> { let marker = tsk_dir.join(GIT_BACKED_MARKER); if marker.exists() { let git_dir = PathBuf::from(fs::read_to_string(&marker)?.trim()); // First: rename any non-namespaced refs into the default namespace, so // workspaces created before namespacing keep working seamlessly. let probe = GitStore::open(git_dir.clone())?; upgrade_to_namespaced(&probe)?; let ns = read_namespace(tsk_dir); let store = GitStore::open_namespace(git_dir, ns)?; upgrade_legacy_keys(&store)?; Ok(Box::new(store)) } else { Ok(Box::new(FileStore::new(tsk_dir.to_path_buf()))) } } /// Move any non-namespaced refs (`refs/tsk/<key>`, `refs/tsk/<bucket>/<id>`) /// into the `default` namespace (`refs/tsk/default/...`). Idempotent. fn upgrade_to_namespaced(probe: &GitStore) -> Result<()> { let repo = probe.repo()?; let strip = format!("{REF_ROOT}/"); let mut moves: Vec<(String, String)> = Vec::new(); for r in repo.references_glob(&format!("{REF_ROOT}/*"))? { let r = r?; let Some(name) = r.name() else { continue }; let Some(rest) = name.strip_prefix(&strip) else { continue; }; // Skip already-namespaced refs: first segment is a known top-level key, // any other first segment is treated as a namespace. let first = rest.split('/').next().unwrap_or(""); let is_legacy = matches!( first, "tasks" | "archive" | "attrs" | "backlinks" | "index" | "next" | "remotes" ); if is_legacy { moves.push(( name.to_string(), format!("{REF_ROOT}/{DEFAULT_NAMESPACE}/{rest}"), )); } } for (old, new) in moves { if let Some(r) = try_ref(&repo, &old)? && let Some(oid) = r.target() { repo.reference(&new, oid, true, "tsk namespace upgrade")?; if let Some(mut r) = try_ref(&repo, &old)? { r.delete()?; } } } Ok(()) } /// Rename legacy-scheme refs (`tasks/tsk-N.tsk`) to the current scheme /// (`tasks/N`). Older versions of the git backend named blobs after the file /// path used by the file backend; the current scheme uses just the integer id. /// Runs on every open so stale workspaces self-heal on first use. fn upgrade_legacy_keys(store: &dyn Store) -> Result<()> { for bucket in ["tasks", "archive"] { for key in store.list(bucket)? { // key looks like "tasks/<name>" — strip prefix to get the leaf. let leaf = key.split('/').next_back().unwrap_or(""); // Legacy names look like "tsk-N.tsk". New names are just "N". if let Some(num) = leaf .strip_prefix("tsk-") .and_then(|s| s.strip_suffix(".tsk")) && num.parse::<u32>().is_ok() { let new_key = format!("{bucket}/{num}"); if store.exists(&new_key)? { // New-scheme blob already present; just drop the legacy one. store.delete(&key)?; continue; } if let Some(data) = store.read(&key)? { store.write(&new_key, &data)?; store.delete(&key)?; } } } } Ok(()) } #[cfg(test)] mod test { use super::*; fn run_git_init(dir: &Path) { let s = std::process::Command::new("git") .args(["init", "-q"]) .current_dir(dir) .status() .unwrap(); assert!(s.success()); } fn store_pair() -> (tempfile::TempDir, Box<dyn Store>, Box<dyn Store>) { let dir = tempfile::tempdir().unwrap(); let file_root = dir.path().join("file"); let git_root = dir.path().join("git"); fs::create_dir_all(&file_root).unwrap(); fs::create_dir_all(&git_root).unwrap(); run_git_init(&git_root); let f: Box<dyn Store> = Box::new(FileStore::new(file_root)); let g: Box<dyn Store> = Box::new(GitStore::open(git_root.join(".git")).unwrap()); (dir, f, g) } #[test] fn test_basic_blob_ops_both_backends() { let (_d, file, git) = store_pair(); for s in [file, git] { assert_eq!(s.read("missing").unwrap(), None); assert!(!s.exists("k").unwrap()); s.write("k", b"hello").unwrap(); assert!(s.exists("k").unwrap()); assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"hello"[..])); s.write("k", b"world").unwrap(); assert_eq!(s.read("k").unwrap().as_deref(), Some(&b"world"[..])); s.delete("k").unwrap(); assert!(!s.exists("k").unwrap()); // delete nonexistent is fine s.delete("k").unwrap(); } } #[test] fn test_list_both_backends() { let (_d, file, git) = store_pair(); for s in [file, git] { s.write("tasks/1", b"a").unwrap(); s.write("tasks/2", b"b").unwrap(); s.write("archive/3", b"c").unwrap(); let mut tasks = s.list("tasks").unwrap(); tasks.sort(); assert_eq!(tasks, vec!["tasks/1", "tasks/2"]); let arch = s.list("archive").unwrap(); assert_eq!(arch, vec!["archive/3"]); assert!(s.list("nothing").unwrap().is_empty()); } } #[test] fn test_high_level_task_ops_both_backends() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { let id = next_id(s).unwrap(); assert_eq!(id, Id(1)); let id2 = next_id(s).unwrap(); assert_eq!(id2, Id(2)); write_task(s, id, "title", "body", Loc::Active, "write", None).unwrap(); let (t, b, loc) = read_task(s, id).unwrap().unwrap(); assert_eq!(t, "title"); assert_eq!(b, "body"); assert_eq!(loc, Loc::Active); move_task(s, id, Loc::Archived).unwrap(); assert_eq!(task_location(s, id).unwrap(), Some(Loc::Archived)); move_task(s, id, Loc::Active).unwrap(); assert_eq!(task_location(s, id).unwrap(), Some(Loc::Active)); let mut attrs = BTreeMap::new(); attrs.insert("foo".to_string(), "bar".to_string()); write_attrs(s, id, &attrs, "write", None).unwrap(); assert_eq!(read_attrs(s, id).unwrap(), attrs); let mut bl = HashSet::new(); bl.insert(Id(7)); bl.insert(Id(9)); write_backlinks(s, id, &bl).unwrap(); assert_eq!(read_backlinks(s, id).unwrap(), bl); // Empty attrs/backlinks delete the blob. write_attrs(s, id, &BTreeMap::new(), "write", None).unwrap(); assert!(read_attrs(s, id).unwrap().is_empty()); write_backlinks(s, id, &HashSet::new()).unwrap(); assert!(read_backlinks(s, id).unwrap().is_empty()); } } #[test] fn test_remotes_round_trip_both_backends() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { assert!(read_remotes(s).unwrap().is_empty()); let remotes = vec![ Remote { prefix: "a".into(), path: PathBuf::from("/x"), }, Remote { prefix: "b".into(), path: PathBuf::from("/y"), }, ]; write_remotes(s, &remotes).unwrap(); assert_eq!(read_remotes(s).unwrap(), remotes); write_remotes(s, &[]).unwrap(); assert!(read_remotes(s).unwrap().is_empty()); } } #[test] fn test_upgrade_legacy_keys_renames_old_scheme() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); run_git_init(&root); let store = GitStore::open(root.join(".git")).unwrap(); // Seed legacy-scheme refs (what older versions of tsk wrote). store.write("tasks/tsk-1.tsk", b"old\n\nbody").unwrap(); store.write("archive/tsk-2.tsk", b"old2\n\nbody2").unwrap(); // And one already-correct new-scheme ref alongside. store.write("tasks/3", b"new\n\nbody3").unwrap(); upgrade_legacy_keys(&store).unwrap(); // Legacy keys should be gone, new-scheme keys present. assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); assert!(!store.exists("archive/tsk-2.tsk").unwrap()); assert_eq!( store.read("tasks/1").unwrap().as_deref(), Some(&b"old\n\nbody"[..]) ); assert_eq!( store.read("archive/2").unwrap().as_deref(), Some(&b"old2\n\nbody2"[..]) ); assert_eq!( store.read("tasks/3").unwrap().as_deref(), Some(&b"new\n\nbody3"[..]) ); } #[test] fn test_upgrade_legacy_keys_keeps_new_when_both_present() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); run_git_init(&root); let store = GitStore::open(root.join(".git")).unwrap(); store.write("tasks/tsk-1.tsk", b"legacy").unwrap(); store.write("tasks/1", b"current").unwrap(); upgrade_legacy_keys(&store).unwrap(); assert!(!store.exists("tasks/tsk-1.tsk").unwrap()); assert_eq!( store.read("tasks/1").unwrap().as_deref(), Some(&b"current"[..]) ); } #[test] fn test_list_active_archive_helpers() { let (_d, file, git) = store_pair(); for s in [file.as_ref(), git.as_ref()] { write_task(s, Id(1), "t1", "", Loc::Active, "write", None).unwrap(); write_task(s, Id(2), "t2", "", Loc::Archived, "write", None).unwrap(); write_task(s, Id(3), "t3", "", Loc::Active, "write", None).unwrap(); assert_eq!(list_active(s).unwrap(), vec![Id(1), Id(3)]); assert_eq!(list_archive(s).unwrap(), vec![Id(2)]); } } }