Something went wrong. Try again.
A git-based task manager
Something went wrong. Try again.
Rust
at feat/parser
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429#![allow(dead_code)]use nix::fcntl::{Flock, FlockArg};use xattr::FileExt;
use crate::attrs::Attrs;use crate::errors::{Error, Result};use crate::stack::{StackItem, TaskStack};use crate::{fzf, util};use std::collections::{vec_deque, BTreeMap};use std::ffi::OsString;use std::fmt::Display;use std::fs::File;use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom};use std::ops::Deref;use std::os::unix::fs::symlink;use std::path::PathBuf;use std::str::FromStr;use std::{fs::OpenOptions, io::Write};
const INDEXFILE: &str = "index";const TITLECACHEFILE: &str = "cache";const XATTRPREFIX: &str = "user.tsk.";/// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`.#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub struct Id(pub u32);
impl FromStr for Id { type Err = Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { let s = s .strip_prefix("tsk-") .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; Ok(Self(s.parse()?)) }}
impl Display for Id { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "tsk-{}", self.0) }}
impl From<u32> for Id { fn from(value: u32) -> Self { Id(value) }}
impl Id { pub fn to_filename(&self) -> String { format!("tsk-{}.tsk", self.0) }}
pub enum TaskIdentifier { Id(Id), Relative(u32), Find { search_body: bool, archived: bool },}
impl From<Id> for TaskIdentifier { fn from(value: Id) -> Self { TaskIdentifier::Id(value) }}
pub struct Workspace { /// The path to the workspace root, excluding the .tsk directory. This should *contain* the /// .tsk directory. path: PathBuf,}
impl Workspace { pub fn init(path: PathBuf) -> Result<()> { // TODO: detect if in a git repo and add .tsk/ to `.git/info/exclude` let tsk_dir = path.join(".tsk"); if tsk_dir.exists() { return Err(Error::AlreadyInitialized); } std::fs::create_dir(&tsk_dir)?; // Create the tasks directory std::fs::create_dir(&tsk_dir.join("tasks"))?; // Create the archive directory std::fs::create_dir(&tsk_dir.join("archive"))?; let mut next = OpenOptions::new() .read(true) .write(true) .create(true) .open(tsk_dir.join("next"))?; next.write_all(b"1\n")?; Ok(()) }
pub fn from_path(path: PathBuf) -> Result<Self> { let tsk_dir = util::find_parent_with_dir(path, ".tsk")?.ok_or(Error::Uninitialized)?; Ok(Self { path: tsk_dir }) }
fn resolve(&self, identifier: TaskIdentifier) -> Result<Id> { match identifier { TaskIdentifier::Id(id) => Ok(id), TaskIdentifier::Relative(r) => { let stack = self.read_stack()?; let stack_item = stack.get(r as usize).ok_or(Error::NoTasks)?; Ok(stack_item.id) } TaskIdentifier::Find { search_body, archived, } => self .search(None, search_body, archived)? .ok_or(Error::NotSelected), } }
/// Increments the `next` counter and returns the previous value. pub fn next_id(&self) -> Result<Id> { let mut file = util::flopen(self.path.join("next"), FlockArg::LockExclusive)?; let mut buf = String::new(); file.read_to_string(&mut buf)?; let id = buf.trim().parse::<u32>()?; // reset the files contents file.set_len(0)?; file.seek(SeekFrom::Start(0))?; // store the *next* if file.write_all(format!("{}\n", id + 1).as_bytes())?; Ok(Id(id)) }
pub fn new_task(&self, title: String, body: String) -> Result<Task> { // WARN: we could improperly increment the id if the task is not written to disk/errors. // But who cares let id = self.next_id()?; // the task goes in the archive first let task_path = self.path.join("archive").join(format!("tsk-{}.tsk", id.0)); let mut file = util::flopen(task_path.clone(), FlockArg::LockExclusive)?; file.write_all(format!("{title}\n\n{body}").as_bytes())?; // create a hardlink to the task dir to mark it as "open" symlink( task_path, self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), )?; Ok(Task { id, title, body, file, attributes: Default::default(), }) }
pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> { let id = self.resolve(identifier)?;
let file = util::flopen( self.path.join("tasks").join(format!("tsk-{}.tsk", id.0)), FlockArg::LockExclusive, )?; let mut title = String::new(); let mut body = String::new(); let mut reader = BufReader::new(&*file); reader.read_line(&mut title)?; reader.read_to_string(&mut body)?; drop(reader); let mut read_attributes = BTreeMap::new(); if let Ok(attrs) = file.list_xattr() { for attr in attrs { if let Some((key, value)) = Self::read_xattr(&file, attr) { read_attributes.insert(key, value); } } } Ok(Task { id, file, title: title.trim().to_string(), body: body.trim().to_string(), attributes: Attrs::from_written(read_attributes), }) }
/// Reads an xattr from a file, stripping the prefix for fn read_xattr<D: Deref<Target = File>>(file: &D, key: OsString) -> Option<(String, String)> { // this *shouldn't* allocate, but it does O(n) scan the str for UTF-8 correctness let parsedkey = key.as_os_str().to_str()?.strip_prefix(XATTRPREFIX)?; let valuebytes = file.get_xattr(&key).ok().flatten()?; Some((parsedkey.to_string(), String::from_utf8(valuebytes).ok()?)) }
pub fn read_stack(&self) -> Result<TaskStack> { TaskStack::from_tskdir(&self.path) }
pub fn push_task(&self, task: Task) -> Result<()> { let mut stack = TaskStack::from_tskdir(&self.path)?; stack.push(task.try_into()?); stack.save()?; Ok(()) }
pub fn swap_top(&self) -> Result<()> { let mut stack = TaskStack::from_tskdir(&self.path)?; stack.swap(); stack.save()?; Ok(()) }
pub fn rot(&self) -> Result<()> { let mut stack = TaskStack::from_tskdir(&self.path)?; let top = stack.pop(); let second = stack.pop(); let third = stack.pop();
if top.is_none() || second.is_none() || third.is_none() { return Ok(()); }
// unwrap is ok here because we checked above stack.push(second.unwrap()); stack.push(top.unwrap()); stack.push(third.unwrap()); stack.save()?; Ok(()) }
/// The inverse of tor. Pushes the top item behind the second item, shifting #2 and #3 to #1 /// and #2 respectively. pub fn tor(&self) -> Result<()> { let mut stack = TaskStack::from_tskdir(&self.path)?; let top = stack.pop(); let second = stack.pop(); let third = stack.pop();
if top.is_none() || second.is_none() || third.is_none() { return Ok(()); }
stack.push(top.unwrap()); stack.push(third.unwrap()); stack.push(second.unwrap()); stack.save()?; Ok(()) }
pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> { let id = self.resolve(identifier)?; let mut stack = self.read_stack()?; let index = &stack.iter().map(|i| i.id).position(|i| i == id); let task = if let Some(index) = index { let prioritized_task = stack.remove(*index); stack.save()?; prioritized_task.map(|t| t.id) } else { None }; Ok(task) }
pub fn search( &self, stack: Option<TaskStack>, search_body: bool, _include_archived: bool, ) -> Result<Option<Id>> { let stack = if let Some(stack) = stack { stack } else { self.read_stack()? }; if search_body { let loader = LazyTaskLoader { files: stack.into_iter(), workspace: self, }; // search the entirety of a task Ok(fzf::select(loader)?.map(|bt| bt.id)) } else { // just search the stack Ok(fzf::select(stack)?.map(|si| si.id)) } }
pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> { let id = self.resolve(identifier)?; let mut stack = self.read_stack()?; let index = &stack.iter().map(|i| i.id).position(|i| i == id); if let Some(index) = index { let prioritized_task = stack.remove(*index); // unwrap here is safe because we just searched for the index and know it exists stack.push(prioritized_task.unwrap()); stack.save()?; } Ok(()) }
pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> { let id = self.resolve(identifier)?; let mut stack = self.read_stack()?; let index = &stack.iter().map(|i| i.id).position(|i| i == id); if let Some(index) = index { let deprioritized_task = stack.remove(*index); // unwrap here is safe because we just searched for the index and know it exists stack.push_back(deprioritized_task.unwrap()); stack.save()?; } Ok(()) }}
pub struct Task { pub id: Id, pub title: String, pub body: String, pub file: Flock<File>, pub attributes: Attrs,}
impl Display for Task { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}\n\n{}", self.title, &self.body) }}
impl Task { /// Consumes a task and saves it to disk. pub fn save(mut self) -> Result<()> { self.file.set_len(0)?; self.file.seek(SeekFrom::Start(0))?; self.file.write_all(self.title.trim().as_bytes())?; self.file.write_all(b"\n\n")?; self.file.write_all(self.body.trim().as_bytes())?; Ok(()) }
fn bare(self) -> SearchTask { SearchTask { id: self.id, title: self.title, body: self.body, } }}
/// A task container without a file handlepub struct SearchTask { pub id: Id, pub title: String, pub body: String,}
impl FromStr for SearchTask { type Err = Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { let (tsk_id, task_content) = s.split_once('\t').ok_or(Error::Parse(format!( "Missing TSK-ID or content or task parse." )))?; let (title, body) = task_content .split_once('\t') .ok_or(Error::Parse(format!("Missing body for task parse.")))?; Ok(Self { id: tsk_id.parse()?, title: title.to_string(), body: body.to_string(), }) }}
impl Display for SearchTask { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}\t{}\t{}", self.id, self.title.trim(), self.body.replace('\n', " ").replace('\r', "") ) }}
struct LazyTaskLoader<'a> { files: vec_deque::IntoIter<StackItem>, workspace: &'a Workspace,}
impl<'a> Iterator for LazyTaskLoader<'a> { type Item = SearchTask;
fn next(&mut self) -> Option<Self::Item> { let stack_item = self.files.next()?; let task = self .workspace .task(TaskIdentifier::Id(stack_item.id)) .ok()?; Some(task.bare()) }}
#[cfg(test)]mod test { use super::*;
#[test] fn test_bare_task_display() { let task = SearchTask { id: Id(123), title: "Hello, world".to_string(), body: "The body of the task.\nAnother line\r\nis here.".to_string(), }; assert_eq!( "tsk-123\tHello, world\tThe body of the task. Another line is here.", task.to_string() ); }
#[test] fn test_task_display() { let task = Task { id: Id(123), title: "Hello, world".to_string(), body: "The body of the task.".to_string(), file: util::flopen("/dev/null".into(), FlockArg::LockShared).unwrap(), attributes: Default::default(), }; assert_eq!("Hello, world\n\nThe body of the task.", task.to_string()); }}