A file-based task manager
0
fork

Configure Feed

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

Refactor: collapse duplicated patterns

- backend: extract read_text/write_or_delete helpers; reduce read_attrs,
write_attrs, read_backlinks, write_backlinks, read_remotes,
write_remotes to a few iterator chains each.
- workspace: factor BODY_ARGS/ID_ARGS constants out of search; replace
LazyTaskLoader with a stack.into_iter().filter_map(...) chain. Extract
Workspace::all_keys helper used by both export_zip and migrate_to_git.
Add git_cmd helper so configure_git_remote_refspecs reuses it.
- stack: parse/serialize as iterator chains; drop empty().

Tests untouched; all 46 still pass. ~150 prod lines removed in this pass
on top of the prior refactor. cargo fmt + cargo clippy clean.

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

+177 -220
+76 -68
src/backend.rs
··· 144 144 return Ok(None); 145 145 }; 146 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())) 147 + Ok(Some( 148 + blob.as_blob() 149 + .ok_or_else(|| Error::Parse("not a blob".into()))? 150 + .content() 151 + .to_vec(), 152 + )) 148 153 } 149 154 150 155 fn write(&self, key: &str, data: &[u8]) -> Result<()> { ··· 172 177 repo.references_glob(&format!("{REF_PREFIX}/{prefix}/*"))? 173 178 .filter_map(|r| { 174 179 r.ok() 175 - .and_then(|r| r.name().and_then(|n| n.strip_prefix(&strip)).map(str::to_string)) 180 + .and_then(|r| { 181 + r.name() 182 + .and_then(|n| n.strip_prefix(&strip)) 183 + .map(str::to_string) 184 + }) 176 185 .map(Ok) 177 186 }) 178 187 .collect() ··· 255 264 } 256 265 257 266 fn list_bucket(store: &dyn Store, bucket: &str) -> Result<Vec<Id>> { 258 - let mut ids = Vec::new(); 259 - for key in store.list(bucket)? { 260 - if let Some(idstr) = key.strip_prefix(&format!("{bucket}/")) 261 - && let Ok(n) = idstr.trim_end_matches(".tsk").parse::<u32>() 262 - { 263 - ids.push(Id(n)); 264 - } 265 - } 267 + let prefix = format!("{bucket}/"); 268 + let mut ids: Vec<Id> = store 269 + .list(bucket)? 270 + .iter() 271 + .filter_map(|k| { 272 + k.strip_prefix(&prefix)? 273 + .trim_end_matches(".tsk") 274 + .parse() 275 + .ok() 276 + .map(Id) 277 + }) 278 + .collect(); 266 279 ids.sort_by_key(|i| i.0); 267 280 Ok(ids) 268 281 } 269 282 283 + /// Read+lossy-decode a blob, returning empty string when absent. 284 + fn read_text(store: &dyn Store, key: &str) -> Result<String> { 285 + Ok(store 286 + .read(key)? 287 + .map(|b| String::from_utf8_lossy(&b).into_owned()) 288 + .unwrap_or_default()) 289 + } 290 + 291 + /// Write `body` to `key`, or delete the blob if `body` is empty. 292 + fn write_or_delete(store: &dyn Store, key: &str, body: &str) -> Result<()> { 293 + if body.is_empty() { 294 + store.delete(key) 295 + } else { 296 + store.write(key, body.as_bytes()) 297 + } 298 + } 299 + 270 300 pub fn read_attrs(store: &dyn Store, id: Id) -> Result<BTreeMap<String, String>> { 271 - let mut out = BTreeMap::new(); 272 - if let Some(data) = store.read(&format!("attrs/{}", id.0))? { 273 - for line in String::from_utf8_lossy(&data).lines() { 274 - if let Some((k, v)) = line.split_once('\t') { 275 - out.insert(k.to_string(), v.to_string()); 276 - } 277 - } 278 - } 279 - Ok(out) 301 + Ok(read_text(store, &format!("attrs/{}", id.0))? 302 + .lines() 303 + .filter_map(|l| { 304 + l.split_once('\t') 305 + .map(|(k, v)| (k.to_string(), v.to_string())) 306 + }) 307 + .collect()) 280 308 } 281 309 282 310 pub fn write_attrs(store: &dyn Store, id: Id, attrs: &BTreeMap<String, String>) -> Result<()> { 283 - if attrs.is_empty() { 284 - return store.delete(&format!("attrs/{}", id.0)); 285 - } 286 - let mut buf = String::new(); 287 - for (k, v) in attrs { 288 - buf.push_str(k); 289 - buf.push('\t'); 290 - buf.push_str(v); 291 - buf.push('\n'); 292 - } 293 - store.write(&format!("attrs/{}", id.0), buf.as_bytes()) 311 + let body = attrs 312 + .iter() 313 + .map(|(k, v)| format!("{k}\t{v}\n")) 314 + .collect::<String>(); 315 + write_or_delete(store, &format!("attrs/{}", id.0), &body) 294 316 } 295 317 296 318 pub fn read_backlinks(store: &dyn Store, id: Id) -> Result<HashSet<Id>> { 297 - let mut out = HashSet::new(); 298 - if let Some(data) = store.read(&format!("backlinks/{}", id.0))? { 299 - for tok in String::from_utf8_lossy(&data).split(',') { 300 - if let Ok(i) = Id::from_str(tok.trim()) { 301 - out.insert(i); 302 - } 303 - } 304 - } 305 - Ok(out) 319 + Ok(read_text(store, &format!("backlinks/{}", id.0))? 320 + .split(',') 321 + .filter_map(|t| Id::from_str(t.trim()).ok()) 322 + .collect()) 306 323 } 307 324 308 325 pub fn write_backlinks(store: &dyn Store, id: Id, links: &HashSet<Id>) -> Result<()> { 309 - if links.is_empty() { 310 - return store.delete(&format!("backlinks/{}", id.0)); 311 - } 312 - let joined = itertools::join(links, ","); 313 - store.write(&format!("backlinks/{}", id.0), joined.as_bytes()) 326 + write_or_delete( 327 + store, 328 + &format!("backlinks/{}", id.0), 329 + &itertools::join(links, ","), 330 + ) 314 331 } 315 332 316 333 pub fn read_remotes(store: &dyn Store) -> Result<Vec<Remote>> { 317 - let mut out = Vec::new(); 318 - if let Some(data) = store.read("remotes")? { 319 - for line in String::from_utf8_lossy(&data).lines() { 320 - let line = line.trim(); 321 - if line.is_empty() || line.starts_with('#') { 322 - continue; 323 - } 324 - if let Some((prefix, path)) = line.split_once('\t') { 325 - out.push(Remote { 326 - prefix: prefix.trim().to_string(), 327 - path: PathBuf::from(path.trim()), 328 - }); 329 - } 330 - } 331 - } 332 - Ok(out) 334 + Ok(read_text(store, "remotes")? 335 + .lines() 336 + .map(str::trim) 337 + .filter(|l| !l.is_empty() && !l.starts_with('#')) 338 + .filter_map(|l| l.split_once('\t')) 339 + .map(|(p, path)| Remote { 340 + prefix: p.trim().into(), 341 + path: PathBuf::from(path.trim()), 342 + }) 343 + .collect()) 333 344 } 334 345 335 346 pub fn write_remotes(store: &dyn Store, remotes: &[Remote]) -> Result<()> { 336 - if remotes.is_empty() { 337 - return store.delete("remotes"); 338 - } 339 - let mut buf = String::new(); 340 - for r in remotes { 341 - buf.push_str(&format!("{}\t{}\n", r.prefix, r.path.display())); 342 - } 343 - store.write("remotes", buf.as_bytes()) 347 + let body: String = remotes 348 + .iter() 349 + .map(|r| format!("{}\t{}\n", r.prefix, r.path.display())) 350 + .collect(); 351 + write_or_delete(store, "remotes", &body) 344 352 } 345 353 346 354 // ─── Detection / construction ──────────────────────────────────────────────
+18 -21
src/stack.rs
··· 70 70 71 71 impl TaskStack { 72 72 pub fn parse(text: &str) -> Result<Self> { 73 - let mut all = VecDeque::new(); 74 - for line in text.lines() { 75 - if line.trim().is_empty() { 76 - continue; 77 - } 78 - all.push_back(line.parse()?); 79 - } 80 - Ok(Self { all }) 73 + text.lines() 74 + .filter(|l| !l.trim().is_empty()) 75 + .map(str::parse) 76 + .collect::<Result<VecDeque<_>>>() 77 + .map(|all| Self { all }) 81 78 } 82 79 83 80 pub fn load(store: &dyn Store) -> Result<Self> { 84 - let raw = store.read("index")?.unwrap_or_default(); 85 - Self::parse(&String::from_utf8_lossy(&raw)) 81 + Self::parse(&String::from_utf8_lossy( 82 + &store.read("index")?.unwrap_or_default(), 83 + )) 86 84 } 87 85 88 86 pub fn serialize(&self) -> String { 89 - let mut s = String::new(); 90 - for item in &self.all { 91 - let ts = item 92 - .modify_time 93 - .duration_since(UNIX_EPOCH) 94 - .map(|d| d.as_secs()) 95 - .unwrap_or(0); 96 - s.push_str(&format!("{item}\t{ts}\n")); 97 - } 98 - s 87 + self.all 88 + .iter() 89 + .map(|i| { 90 + let ts = i 91 + .modify_time 92 + .duration_since(UNIX_EPOCH) 93 + .map_or(0, |d| d.as_secs()); 94 + format!("{i}\t{ts}\n") 95 + }) 96 + .collect() 99 97 } 100 98 101 99 pub fn save(&self, store: &dyn Store) -> Result<()> { ··· 142 140 pub fn position(&self, id: Id) -> Option<usize> { 143 141 self.all.iter().position(|i| i.id == id) 144 142 } 145 - 146 143 } 147 144 148 145 impl IntoIterator for TaskStack {
+83 -131
src/workspace.rs
··· 7 7 use crate::stack::{StackItem, TaskStack}; 8 8 use crate::task::parse as parse_task; 9 9 use crate::{fzf, util}; 10 - use std::collections::{BTreeMap, HashSet, vec_deque}; 10 + use std::collections::{BTreeMap, HashSet}; 11 11 use std::fmt::Display; 12 12 use std::path::PathBuf; 13 13 use std::str::FromStr; ··· 238 238 self.mutate_stack(|stack| { 239 239 if let (Some(a), Some(b), Some(c)) = (stack.pop(), stack.pop(), stack.pop()) { 240 240 if swap_third_with_top { 241 - stack.push(b); stack.push(a); stack.push(c); 241 + stack.push(b); 242 + stack.push(a); 243 + stack.push(c); 242 244 } else { 243 - stack.push(a); stack.push(c); stack.push(b); 245 + stack.push(a); 246 + stack.push(c); 247 + stack.push(b); 244 248 } 245 249 } 246 250 }) 247 251 } 248 252 249 - pub fn rot(&self) -> Result<()> { self.rotate_top3(true) } 250 - pub fn tor(&self) -> Result<()> { self.rotate_top3(false) } 253 + pub fn rot(&self) -> Result<()> { 254 + self.rotate_top3(true) 255 + } 256 + pub fn tor(&self) -> Result<()> { 257 + self.rotate_top3(false) 258 + } 251 259 252 260 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { 253 261 let id = self.resolve(identifier)?; ··· 272 280 search_body: bool, 273 281 include_archived: bool, 274 282 ) -> Result<Option<Id>> { 275 - let stack = if let Some(stack) = stack { 276 - stack 277 - } else { 278 - self.read_stack()? 279 - }; 283 + const BODY_ARGS: &[&str] = &[ 284 + "--no-multi-line", 285 + "--accept-nth=1", 286 + "--delimiter=\t", 287 + "--preview=tsk show -T {1}", 288 + "--preview-window=top", 289 + "--ansi", 290 + "--info-command=tsk show -T {1} | head -n1", 291 + "--info=inline-right", 292 + ]; 293 + const ID_ARGS: &[&str] = &["--delimiter=\t", "--accept-nth=1"]; 294 + let args = if search_body { BODY_ARGS } else { ID_ARGS }; 295 + let stack = stack.map_or_else(|| self.read_stack(), Ok)?; 280 296 if include_archived { 281 - let mut all_tasks: Vec<SearchTask> = Vec::new(); 282 297 let mut seen: HashSet<Id> = HashSet::new(); 283 - for item in stack.iter() { 284 - if let Ok(t) = self.task(TaskIdentifier::Id(item.id)) { 298 + let mut all: Vec<SearchTask> = stack 299 + .iter() 300 + .filter_map(|item| self.task(TaskIdentifier::Id(item.id)).ok().map(Task::bare)) 301 + .inspect(|t| { 285 302 seen.insert(t.id); 286 - all_tasks.push(t.bare()); 287 - } 288 - } 303 + }) 304 + .collect(); 289 305 for id in backend::list_archive(self.store())? { 290 - if seen.contains(&id) { 291 - continue; 292 - } 293 - if let Some((title, body, _)) = backend::read_task(self.store(), id)? { 294 - all_tasks.push(SearchTask { id, title, body }); 306 + if !seen.contains(&id) 307 + && let Some((title, body, _)) = backend::read_task(self.store(), id)? 308 + { 309 + all.push(SearchTask { id, title, body }); 295 310 } 296 311 } 297 - if search_body { 298 - Ok(fzf::select::<_, Id, _>( 299 - all_tasks, 300 - [ 301 - "--no-multi-line", 302 - "--accept-nth=1", 303 - "--delimiter=\t", 304 - "--preview=tsk show -T {1}", 305 - "--preview-window=top", 306 - "--ansi", 307 - "--info-command=tsk show -T {1} | head -n1", 308 - "--info=inline-right", 309 - ], 310 - )?) 311 - } else { 312 - Ok(fzf::select::<_, Id, _>( 313 - all_tasks, 314 - ["--delimiter=\t", "--accept-nth=1"], 315 - )?) 316 - } 312 + fzf::select::<_, Id, _>(all, args) 317 313 } else if search_body { 318 - let loader = LazyTaskLoader { 319 - items: stack.into_iter(), 320 - workspace: self, 321 - }; 322 - Ok(fzf::select::<_, Id, _>( 323 - loader, 324 - [ 325 - "--no-multi-line", 326 - "--accept-nth=1", 327 - "--delimiter=\t", 328 - "--preview=tsk show -T {1}", 329 - "--preview-window=top", 330 - "--ansi", 331 - "--info-command=tsk show -T {1} | head -n1", 332 - "--info=inline-right", 333 - ], 334 - )?) 314 + fzf::select::<_, Id, _>( 315 + stack 316 + .into_iter() 317 + .filter_map(|item| self.task(TaskIdentifier::Id(item.id)).ok().map(Task::bare)), 318 + args, 319 + ) 335 320 } else { 336 - Ok(fzf::select::<_, Id, _>( 337 - stack, 338 - ["--delimiter=\t", "--accept-nth=1"], 339 - )?) 321 + fzf::select::<_, Id, _>(stack, args) 340 322 } 341 323 } 342 324 ··· 346 328 if let Some(idx) = stack.position(id) 347 329 && let Some(item) = stack.remove(idx) 348 330 { 349 - if to_front { stack.push(item) } else { stack.push_back(item) } 331 + if to_front { 332 + stack.push(item) 333 + } else { 334 + stack.push_back(item) 335 + } 350 336 } 351 337 }) 352 338 } ··· 418 404 Ok(PathBuf::from(marker.trim())) 419 405 } 420 406 407 + fn git_cmd(&self) -> Result<std::process::Command> { 408 + let mut c = std::process::Command::new("git"); 409 + c.arg("--git-dir").arg(self.require_git_dir()?); 410 + Ok(c) 411 + } 412 + 421 413 fn run_git(&self, args: &[&str]) -> Result<()> { 422 - let git_dir = self.require_git_dir()?; 423 - let status = std::process::Command::new("git") 424 - .arg("--git-dir") 425 - .arg(&git_dir) 426 - .args(args) 427 - .status()?; 414 + let status = self.git_cmd()?.args(args).status()?; 428 415 if !status.success() { 429 416 return Err(Error::Parse(format!("git {args:?} exited with {status}"))); 430 417 } ··· 444 431 /// Configure git so future `git push <remote>` / `git fetch <remote>` 445 432 /// include the tsk ref namespace. Idempotent. 446 433 pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> { 447 - let git_dir = self.require_git_dir()?; 448 434 for (key, value) in [ 449 435 (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"), 450 436 (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"), 451 437 ] { 452 - // Read existing values; skip if our refspec is already present. 453 - let existing = std::process::Command::new("git") 454 - .arg("--git-dir") 455 - .arg(&git_dir) 438 + let existing = self 439 + .git_cmd()? 456 440 .args(["config", "--get-all", &key]) 457 441 .output()?; 458 - let existing_text = String::from_utf8_lossy(&existing.stdout); 459 - if existing_text.lines().any(|l| l.trim() == value) { 442 + if String::from_utf8_lossy(&existing.stdout) 443 + .lines() 444 + .any(|l| l.trim() == value) 445 + { 460 446 continue; 461 447 } 462 - let status = std::process::Command::new("git") 463 - .arg("--git-dir") 464 - .arg(&git_dir) 465 - .args(["config", "--add", &key, value]) 466 - .status()?; 467 - if !status.success() { 468 - return Err(Error::Parse(format!("git config --add {key} failed"))); 469 - } 448 + self.run_git(&["config", "--add", &key, value])?; 470 449 } 471 450 Ok(()) 472 451 } 473 452 474 - /// Write a zip archive containing every blob in the workspace. Layout in the 475 - /// zip mirrors the logical key namespace (`tasks/<id>`, `archive/<id>`, 476 - /// `attrs/<id>`, `backlinks/<id>`, `index`, `next`, `remotes`). 477 - pub fn export_zip(&self, dest: &std::path::Path) -> Result<()> { 478 - let file = std::fs::File::create(dest)?; 479 - let mut writer = zip::ZipWriter::new(file); 480 - let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default() 481 - .compression_method(zip::CompressionMethod::Deflated); 482 - 453 + /// Every logical blob key that currently exists in the workspace. 454 + fn all_keys(&self) -> Result<Vec<String>> { 483 455 let mut keys: Vec<String> = Vec::new(); 484 456 for prefix in ["tasks", "archive", "attrs", "backlinks"] { 485 457 keys.extend(self.store().list(prefix)?); 486 458 } 487 459 for top in ["index", "next", "remotes"] { 488 460 if self.store().exists(top)? { 489 - keys.push(top.to_string()); 461 + keys.push(top.into()); 490 462 } 491 463 } 492 464 keys.sort(); 465 + Ok(keys) 466 + } 493 467 468 + /// Write a zip archive containing every blob in the workspace. Layout in the 469 + /// zip mirrors the logical key namespace. 470 + pub fn export_zip(&self, dest: &std::path::Path) -> Result<()> { 471 + let mut writer = zip::ZipWriter::new(std::fs::File::create(dest)?); 472 + let opts = zip::write::SimpleFileOptions::default() 473 + .compression_method(zip::CompressionMethod::Deflated); 494 474 use std::io::Write as _; 495 - for key in keys { 475 + for key in self.all_keys()? { 496 476 if let Some(data) = self.store().read(&key)? { 497 477 writer 498 478 .start_file(&key, opts) 499 - .map_err(|e| Error::Parse(format!("zip start_file: {e}")))?; 479 + .map_err(|e| Error::Parse(format!("zip: {e}")))?; 500 480 writer.write_all(&data)?; 501 481 } 502 482 } 503 483 writer 504 484 .finish() 505 - .map_err(|e| Error::Parse(format!("zip finish: {e}")))?; 485 + .map_err(|e| Error::Parse(format!("zip: {e}")))?; 506 486 Ok(()) 507 487 } 508 488 509 489 /// Migrate a file-backed workspace to a git-backed one. Returns Err if the 510 490 /// workspace is already git-backed or if no enclosing git repo is found. 511 - /// All blobs are copied into refs/tsk/* and the on-disk task data is then 512 - /// removed, leaving only the `.tsk/git-backed` marker. 513 491 pub fn migrate_to_git(&self) -> Result<PathBuf> { 514 492 if self.is_git_backed() { 515 493 return Err(Error::Parse("Workspace is already git-backed".into())); ··· 517 495 let git_dir = backend::detect_git_dir(&self.path) 518 496 .ok_or_else(|| Error::Parse("No enclosing git repository found".into()))?; 519 497 let dest = backend::GitStore::open(git_dir.clone())?; 520 - // Copy every logical blob across. 521 - let prefixes = ["tasks", "archive", "attrs", "backlinks"]; 522 - for prefix in prefixes { 523 - for key in self.store().list(prefix)? { 524 - if let Some(data) = self.store().read(&key)? { 525 - dest.write(&key, &data)?; 526 - } 527 - } 528 - } 529 - for top in ["index", "next", "remotes"] { 530 - if let Some(data) = self.store().read(top)? { 531 - dest.write(top, &data)?; 498 + for key in self.all_keys()? { 499 + if let Some(data) = self.store().read(&key)? { 500 + dest.write(&key, &data)?; 532 501 } 533 502 } 534 - // Drop on-disk file backend state: everything under .tsk/ except the 535 - // marker we're about to write. 536 503 for entry in std::fs::read_dir(&self.path)? { 537 - let entry = entry?; 538 - let p = entry.path(); 504 + let p = entry?.path(); 539 505 if p.is_dir() { 540 - std::fs::remove_dir_all(&p)?; 506 + std::fs::remove_dir_all(&p)? 541 507 } else { 542 - std::fs::remove_file(&p)?; 508 + std::fs::remove_file(&p)? 543 509 } 544 510 } 545 511 std::fs::write( ··· 606 572 write!(f, "\n\n{}", self.body)?; 607 573 } 608 574 Ok(()) 609 - } 610 - } 611 - 612 - struct LazyTaskLoader<'a> { 613 - items: vec_deque::IntoIter<StackItem>, 614 - workspace: &'a Workspace, 615 - } 616 - 617 - impl Iterator for LazyTaskLoader<'_> { 618 - type Item = SearchTask; 619 - fn next(&mut self) -> Option<Self::Item> { 620 - let item = self.items.next()?; 621 - let task = self.workspace.task(TaskIdentifier::Id(item.id)).ok()?; 622 - Some(task.bare()) 623 575 } 624 576 } 625 577