A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor: drop attrs indirection, merge similar workspace methods

- Replace the Attrs (written/updated split) struct with a plain
BTreeMap<String, String>. The split was an optimization for the old xattr
storage; with refs/files as blobs, every save rewrites the whole map and
the indirection is just noise. Tests use the same insert/get/iter API
unchanged.
- Add Workspace::mutate_stack helper and use it for push_task, append_task,
swap_top, prioritize, deprioritize. Merge rot/tor into rotate_top3 with
a flag, prioritize/deprioritize into move_in_stack.
- Add From<git2::Error> for Error so GitStore methods use ? directly
instead of mapping each error site. Add try_ref helper for the common
"find_reference, NotFound → None" pattern.
- Drop unused: Id::filename, TaskStack::refresh_titles, From<Task> for
StackItem.

Functionality preserved; all 46 tests pass without modification. Net
~120 lines removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+68 -190
-61
src/attrs.rs
··· 1 - use std::collections::BTreeMap; 2 - use std::collections::btree_map::Entry; 3 - use std::collections::btree_map::{IntoIter as BTreeIntoIter, Iter as BTreeMapIter}; 4 - use std::iter::Chain; 5 - 6 - type Map = BTreeMap<String, String>; 7 - 8 - #[allow(dead_code)] 9 - /// Holds xattributes in a way that allows for differentiating between attributes that have been 10 - /// added/modified or that were present when reading the file. This is an *optimization* over 11 - /// infrequently modified values. 12 - #[derive(Default, Clone, Debug)] 13 - pub(crate) struct Attrs { 14 - pub written: Map, 15 - pub updated: Map, 16 - } 17 - 18 - impl IntoIterator for Attrs { 19 - type Item = (String, String); 20 - 21 - type IntoIter = Chain<BTreeIntoIter<String, String>, BTreeIntoIter<String, String>>; 22 - 23 - fn into_iter(self) -> Self::IntoIter { 24 - self.written.into_iter().chain(self.updated) 25 - } 26 - } 27 - 28 - #[allow(dead_code)] 29 - impl Attrs { 30 - pub(crate) fn from_written(written: Map) -> Self { 31 - Self { 32 - written, 33 - ..Default::default() 34 - } 35 - } 36 - 37 - pub(crate) fn get(&self, key: &str) -> Option<&String> { 38 - self.updated.get(key).or_else(|| self.written.get(key)) 39 - } 40 - 41 - pub(crate) fn insert(&mut self, key: String, value: String) -> Option<String> { 42 - match self.updated.entry(key.clone()) { 43 - Entry::Occupied(mut e) => Some(e.insert(value)), 44 - Entry::Vacant(e) => { 45 - e.insert(value); 46 - let maybe_old_value = self.written.get(&key); 47 - maybe_old_value.cloned() 48 - } 49 - } 50 - } 51 - 52 - pub(crate) fn is_empty(&self) -> bool { 53 - self.updated.is_empty() && self.written.is_empty() 54 - } 55 - 56 - pub(crate) fn iter( 57 - &self, 58 - ) -> Chain<BTreeMapIter<'_, String, String>, BTreeMapIter<'_, String, String>> { 59 - self.written.iter().chain(self.updated.iter()) 60 - } 61 - }
+29 -52
src/backend.rs
··· 14 14 15 15 use crate::errors::{Error, Result}; 16 16 use crate::workspace::{Id, Remote}; 17 - use git2::{ObjectType, Oid, Repository}; 17 + use git2::{ObjectType, Reference, Repository}; 18 18 use std::collections::{BTreeMap, HashSet}; 19 19 use std::fs::{self, OpenOptions}; 20 20 use std::io::Write; ··· 115 115 116 116 impl GitStore { 117 117 pub fn open(git_dir: PathBuf) -> Result<Self> { 118 - // Validate by opening once. 119 - Repository::open(&git_dir).map_err(|e| Error::Parse(format!("git open failed: {e}")))?; 118 + Repository::open(&git_dir)?; 120 119 Ok(Self { git_dir }) 121 120 } 122 121 123 122 fn repo(&self) -> Result<Repository> { 124 - Repository::open(&self.git_dir).map_err(|e| Error::Parse(format!("git open: {e}"))) 123 + Ok(Repository::open(&self.git_dir)?) 125 124 } 126 125 127 126 fn refname(key: &str) -> String { ··· 129 128 } 130 129 } 131 130 132 - fn read_blob(repo: &Repository, refname: &str) -> Result<Option<(Oid, Vec<u8>)>> { 133 - let r = match repo.find_reference(refname) { 134 - Ok(r) => r, 135 - Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None), 136 - Err(e) => return Err(Error::Parse(format!("find_reference {refname}: {e}"))), 137 - }; 138 - let obj = r 139 - .peel(ObjectType::Blob) 140 - .map_err(|e| Error::Parse(format!("peel: {e}")))?; 141 - let blob = obj 142 - .as_blob() 143 - .ok_or_else(|| Error::Parse("not a blob".into()))?; 144 - Ok(Some((obj.id(), blob.content().to_vec()))) 131 + /// `find_reference` translating NotFound to None. 132 + fn try_ref<'r>(repo: &'r Repository, name: &str) -> Result<Option<Reference<'r>>> { 133 + match repo.find_reference(name) { 134 + Ok(r) => Ok(Some(r)), 135 + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), 136 + Err(e) => Err(e.into()), 137 + } 145 138 } 146 139 147 140 impl Store for GitStore { 148 141 fn read(&self, key: &str) -> Result<Option<Vec<u8>>> { 149 142 let repo = self.repo()?; 150 - Ok(read_blob(&repo, &Self::refname(key))?.map(|(_, data)| data)) 143 + let Some(r) = try_ref(&repo, &Self::refname(key))? else { 144 + return Ok(None); 145 + }; 146 + let blob = r.peel(ObjectType::Blob)?; 147 + Ok(Some(blob.as_blob().ok_or_else(|| Error::Parse("not a blob".into()))?.content().to_vec())) 151 148 } 152 149 153 150 fn write(&self, key: &str, data: &[u8]) -> Result<()> { 154 151 let repo = self.repo()?; 155 - let oid = repo 156 - .blob(data) 157 - .map_err(|e| Error::Parse(format!("blob write: {e}")))?; 158 - repo.reference(&Self::refname(key), oid, true, "tsk write") 159 - .map_err(|e| Error::Parse(format!("update ref: {e}")))?; 152 + let oid = repo.blob(data)?; 153 + repo.reference(&Self::refname(key), oid, true, "tsk write")?; 160 154 Ok(()) 161 155 } 162 156 163 157 fn delete(&self, key: &str) -> Result<()> { 164 158 let repo = self.repo()?; 165 - let refname = Self::refname(key); 166 - match repo.find_reference(&refname) { 167 - Ok(mut r) => { 168 - r.delete() 169 - .map_err(|e| Error::Parse(format!("delete ref: {e}")))?; 170 - Ok(()) 171 - } 172 - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()), 173 - Err(e) => Err(Error::Parse(format!("find_reference: {e}"))), 159 + if let Some(mut r) = try_ref(&repo, &Self::refname(key))? { 160 + r.delete()?; 174 161 } 162 + Ok(()) 175 163 } 176 164 177 165 fn exists(&self, key: &str) -> Result<bool> { 178 - let repo = self.repo()?; 179 - match repo.find_reference(&Self::refname(key)) { 180 - Ok(_) => Ok(true), 181 - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(false), 182 - Err(e) => Err(Error::Parse(format!("find_reference: {e}"))), 183 - } 166 + Ok(try_ref(&self.repo()?, &Self::refname(key))?.is_some()) 184 167 } 185 168 186 169 fn list(&self, prefix: &str) -> Result<Vec<String>> { 187 170 let repo = self.repo()?; 188 - let glob = format!("{REF_PREFIX}/{prefix}/*"); 189 - let mut out = Vec::new(); 190 - let refs = repo 191 - .references_glob(&glob) 192 - .map_err(|e| Error::Parse(format!("references_glob: {e}")))?; 193 - for r in refs { 194 - let r = r.map_err(|e| Error::Parse(format!("ref iter: {e}")))?; 195 - if let Some(name) = r.name() 196 - && let Some(stripped) = name.strip_prefix(&format!("{REF_PREFIX}/")) 197 - { 198 - out.push(stripped.to_string()); 199 - } 200 - } 201 - Ok(out) 171 + let strip = format!("{REF_PREFIX}/"); 172 + repo.references_glob(&format!("{REF_PREFIX}/{prefix}/*"))? 173 + .filter_map(|r| { 174 + r.ok() 175 + .and_then(|r| r.name().and_then(|n| n.strip_prefix(&strip)).map(str::to_string)) 176 + .map(Ok) 177 + }) 178 + .collect() 202 179 } 203 180 } 204 181
+2
src/errors.rs
··· 12 12 AlreadyInitialized, 13 13 #[error("Unable to read file: {0}")] 14 14 Io(#[from] std::io::Error), 15 + #[error("git error: {0}")] 16 + Git(#[from] git2::Error), 15 17 #[error("Unable to parse id: {0}")] 16 18 ParseId(#[from] std::num::ParseIntError), 17 19 #[error("General parsing error: {0}")]
-1
src/main.rs
··· 1 - mod attrs; 2 1 mod backend; 3 2 mod errors; 4 3 mod fzf;
-15
src/stack.rs
··· 38 38 } 39 39 } 40 40 41 - impl From<Task> for StackItem { 42 - fn from(value: Task) -> Self { 43 - Self::from(&value) 44 - } 45 - } 46 - 47 41 impl FromStr for StackItem { 48 42 type Err = Error; 49 43 ··· 149 143 self.all.iter().position(|i| i.id == id) 150 144 } 151 145 152 - /// Refresh stack item titles from authoritative task content. 153 - pub fn refresh_titles(&mut self, store: &dyn Store) -> Result<()> { 154 - for item in self.all.iter_mut() { 155 - if let Some((title, _, _)) = crate::backend::read_task(store, item.id)? { 156 - item.title = title.replace('\t', " "); 157 - } 158 - } 159 - Ok(()) 160 - } 161 146 } 162 147 163 148 impl IntoIterator for TaskStack {
+37 -61
src/workspace.rs
··· 2 2 //! High-level workspace API. The workspace owns a [`Store`](crate::backend::Store) 3 3 //! and exposes typed task / stack / remote operations on top of it. 4 4 5 - use crate::attrs::Attrs; 6 5 use crate::backend::{self, Loc, Store}; 7 6 use crate::errors::{Error, Result}; 8 7 use crate::stack::{StackItem, TaskStack}; ··· 38 37 impl From<u32> for Id { 39 38 fn from(value: u32) -> Self { 40 39 Id(value) 41 - } 42 - } 43 - 44 - impl Id { 45 - pub fn filename(&self) -> String { 46 - format!("tsk-{}.tsk", self.0) 47 40 } 48 41 } 49 42 ··· 155 148 let id = self.resolve(identifier)?; 156 149 let (title, body, _loc) = backend::read_task(self.store(), id)? 157 150 .ok_or_else(|| Error::Parse(format!("Task {id} not found")))?; 158 - let attrs_map = backend::read_attrs(self.store(), id)?; 159 151 Ok(Task { 160 152 id, 161 153 title, 162 154 body, 163 - attributes: Attrs::from_written(attrs_map), 155 + attributes: backend::read_attrs(self.store(), id)?, 164 156 }) 165 157 } 166 158 ··· 170 162 None => Loc::Active, 171 163 }; 172 164 backend::write_task(self.store(), task.id, &task.title, &task.body, loc)?; 173 - // Persist any modified attrs. 174 - let mut combined: BTreeMap<String, String> = task.attributes.written.clone(); 175 - for (k, v) in task.attributes.updated.iter() { 176 - combined.insert(k.clone(), v.clone()); 177 - } 178 - backend::write_attrs(self.store(), task.id, &combined)?; 165 + backend::write_attrs(self.store(), task.id, &task.attributes)?; 179 166 // After editing, refresh stack title for this id. 180 167 self.update_stack_title(task.id, &task.title)?; 181 168 Ok(()) ··· 228 215 TaskStack::load(self.store()) 229 216 } 230 217 231 - pub fn push_task(&self, task: Task) -> Result<()> { 218 + /// Run `f` on the workspace stack and persist the result. 219 + fn mutate_stack<F: FnOnce(&mut TaskStack)>(&self, f: F) -> Result<()> { 232 220 let mut stack = self.read_stack()?; 233 - stack.push((&task).into()); 221 + f(&mut stack); 234 222 stack.save(self.store()) 235 223 } 236 224 225 + pub fn push_task(&self, task: Task) -> Result<()> { 226 + self.mutate_stack(|s| s.push((&task).into())) 227 + } 228 + 237 229 pub fn append_task(&self, task: Task) -> Result<()> { 238 - let mut stack = self.read_stack()?; 239 - stack.push_back((&task).into()); 240 - stack.save(self.store()) 230 + self.mutate_stack(|s| s.push_back((&task).into())) 241 231 } 242 232 243 233 pub fn swap_top(&self) -> Result<()> { 244 - let mut stack = self.read_stack()?; 245 - stack.swap(); 246 - stack.save(self.store()) 234 + self.mutate_stack(|s| s.swap()) 247 235 } 248 236 249 - pub fn rot(&self) -> Result<()> { 250 - let mut stack = self.read_stack()?; 251 - let (a, b, c) = (stack.pop(), stack.pop(), stack.pop()); 252 - if let (Some(a), Some(b), Some(c)) = (a, b, c) { 253 - stack.push(b); 254 - stack.push(a); 255 - stack.push(c); 256 - stack.save(self.store())?; 257 - } 258 - Ok(()) 237 + fn rotate_top3(&self, swap_third_with_top: bool) -> Result<()> { 238 + self.mutate_stack(|stack| { 239 + if let (Some(a), Some(b), Some(c)) = (stack.pop(), stack.pop(), stack.pop()) { 240 + if swap_third_with_top { 241 + stack.push(b); stack.push(a); stack.push(c); 242 + } else { 243 + stack.push(a); stack.push(c); stack.push(b); 244 + } 245 + } 246 + }) 259 247 } 260 248 261 - pub fn tor(&self) -> Result<()> { 262 - let mut stack = self.read_stack()?; 263 - let (a, b, c) = (stack.pop(), stack.pop(), stack.pop()); 264 - if let (Some(a), Some(b), Some(c)) = (a, b, c) { 265 - stack.push(a); 266 - stack.push(c); 267 - stack.push(b); 268 - stack.save(self.store())?; 269 - } 270 - Ok(()) 271 - } 249 + pub fn rot(&self) -> Result<()> { self.rotate_top3(true) } 250 + pub fn tor(&self) -> Result<()> { self.rotate_top3(false) } 272 251 273 252 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 274 253 let id = self.resolve(identifier)?; ··· 361 340 } 362 341 } 363 342 343 + fn move_in_stack(&self, identifier: TaskIdentifier, to_front: bool) -> Result<()> { 344 + let id = self.resolve(identifier)?; 345 + self.mutate_stack(|stack| { 346 + if let Some(idx) = stack.position(id) 347 + && let Some(item) = stack.remove(idx) 348 + { 349 + if to_front { stack.push(item) } else { stack.push_back(item) } 350 + } 351 + }) 352 + } 353 + 364 354 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> { 365 - let id = self.resolve(identifier)?; 366 - let mut stack = self.read_stack()?; 367 - if let Some(idx) = stack.position(id) { 368 - let task = stack.remove(idx).unwrap(); 369 - stack.push(task); 370 - stack.save(self.store())?; 371 - } 372 - Ok(()) 355 + self.move_in_stack(identifier, true) 373 356 } 374 357 375 358 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> { 376 - let id = self.resolve(identifier)?; 377 - let mut stack = self.read_stack()?; 378 - if let Some(idx) = stack.position(id) { 379 - let task = stack.remove(idx).unwrap(); 380 - stack.push_back(task); 381 - stack.save(self.store())?; 382 - } 383 - Ok(()) 359 + self.move_in_stack(identifier, false) 384 360 } 385 361 386 362 /// Remove "active" task entries that aren't in the index. ··· 598 574 pub id: Id, 599 575 pub title: String, 600 576 pub body: String, 601 - pub attributes: Attrs, 577 + pub attributes: BTreeMap<String, String>, 602 578 } 603 579 604 580 impl Display for Task {