diff --git a/cli/src/cmd/debug.rs b/cli/src/cmd/debug.rs new file mode 100644 index 000000000..f81f605b7 --- /dev/null +++ b/cli/src/cmd/debug.rs @@ -0,0 +1,57 @@ +use crate::table; +use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; +use failure::Fallible; +use prettytable::{cell, row, Table}; + +use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; + +#[derive(Debug)] +struct Invocation { + task: String, +} + +define_subcommand! { + fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { + app.subcommand( + ClapSubCommand::with_name("debug") + .about("debug info for the given task") + .arg(shared::task_arg())) + } + + fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { + match matches.subcommand() { + ("debug", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { + task: matches.value_of("task").unwrap().into(), + })), + _ => ArgMatchResult::None, + } + } +} + +subcommand_invocation! { + fn run(&self, command: &CommandInvocation) -> Fallible<()> { + let mut replica = command.get_replica(); + let task = shared::get_task(&mut replica, &self.task)?; + + let mut t = Table::new(); + t.set_format(table::format()); + t.set_titles(row![b->"key", b->"value"]); + for (k, v) in task.get_taskmap().iter() { + t.add_row(row![k, v]); + } + t.printstd(); + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn parse_command() { + with_subcommand_invocation!(vec!["task", "debug", "1"], |inv: &Invocation| { + assert_eq!(inv.task, "1".to_string()); + }); + } +} diff --git a/cli/src/cmd/info.rs b/cli/src/cmd/info.rs index f55667dfb..919e47a76 100644 --- a/cli/src/cmd/info.rs +++ b/cli/src/cmd/info.rs @@ -42,6 +42,7 @@ subcommand_invocation! { } t.add_row(row![b->"Description", task.get_description()]); t.add_row(row![b->"Status", task.get_status()]); + t.add_row(row![b->"Active", task.is_active()]); t.printstd(); Ok(()) } diff --git a/cli/src/cmd/list.rs b/cli/src/cmd/list.rs index 72ee206e0..4dddd5265 100644 --- a/cli/src/cmd/list.rs +++ b/cli/src/cmd/list.rs @@ -26,13 +26,17 @@ subcommand_invocation! { let mut replica = command.get_replica()?; let mut t = Table::new(); t.set_format(table::format()); - t.set_titles(row![b->"id", b->"description"]); + t.set_titles(row![b->"id", b->"act", b->"description"]); for (uuid, task) in replica.all_tasks().unwrap() { let mut id = uuid.to_string(); if let Some(i) = replica.get_working_set_index(&uuid)? { id = i.to_string(); } - t.add_row(row![id, task.get_description()]); + let active = match task.is_active() { + true => "*", + false => "", + }; + t.add_row(row![id, active, task.get_description()]); } t.printstd(); Ok(()) diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs index 2705c90c0..d58237816 100644 --- a/cli/src/cmd/mod.rs +++ b/cli/src/cmd/mod.rs @@ -6,22 +6,28 @@ mod macros; mod shared; mod add; +mod debug; mod gc; mod info; mod list; mod modify; mod pending; +mod start; +mod stop; mod sync; /// Get a list of all subcommands in this crate pub(crate) fn subcommands() -> Vec> { vec![ add::cmd(), + debug::cmd(), gc::cmd(), info::cmd(), list::cmd(), modify::cmd(), pending::cmd(), + start::cmd(), + stop::cmd(), sync::cmd(), ] } diff --git a/cli/src/cmd/pending.rs b/cli/src/cmd/pending.rs index f35756b8b..f4f99974a 100644 --- a/cli/src/cmd/pending.rs +++ b/cli/src/cmd/pending.rs @@ -28,10 +28,14 @@ subcommand_invocation! { let working_set = command.get_replica()?.working_set().unwrap(); let mut t = Table::new(); t.set_format(table::format()); - t.set_titles(row![b->"id", b->"description"]); + t.set_titles(row![b->"id", b->"act", b->"description"]); for (i, item) in working_set.iter().enumerate() { if let Some(ref task) = item { - t.add_row(row![i, task.get_description()]); + let active = match task.is_active() { + true => "*", + false => "", + }; + t.add_row(row![i, active, task.get_description()]); } } t.printstd(); diff --git a/cli/src/cmd/start.rs b/cli/src/cmd/start.rs new file mode 100644 index 000000000..95365003c --- /dev/null +++ b/cli/src/cmd/start.rs @@ -0,0 +1,48 @@ +use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; +use failure::Fallible; + +use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; + +#[derive(Debug)] +struct Invocation { + task: String, +} + +define_subcommand! { + fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { + app.subcommand( + ClapSubCommand::with_name("start") + .about("start the given task") + .arg(shared::task_arg())) + } + + fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { + match matches.subcommand() { + ("start", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { + task: matches.value_of("task").unwrap().into(), + })), + _ => ArgMatchResult::None, + } + } +} + +subcommand_invocation! { + fn run(&self, command: &CommandInvocation) -> Fallible<()> { + let mut replica = command.get_replica(); + let task = shared::get_task(&mut replica, &self.task)?; + task.into_mut(&mut replica).start()?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn parse_command() { + with_subcommand_invocation!(vec!["task", "start", "1"], |inv: &Invocation| { + assert_eq!(inv.task, "1".to_string()); + }); + } +} diff --git a/cli/src/cmd/stop.rs b/cli/src/cmd/stop.rs new file mode 100644 index 000000000..d61eb8b51 --- /dev/null +++ b/cli/src/cmd/stop.rs @@ -0,0 +1,48 @@ +use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; +use failure::Fallible; + +use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; + +#[derive(Debug)] +struct Invocation { + task: String, +} + +define_subcommand! { + fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { + app.subcommand( + ClapSubCommand::with_name("stop") + .about("stop the given task") + .arg(shared::task_arg())) + } + + fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { + match matches.subcommand() { + ("stop", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { + task: matches.value_of("task").unwrap().into(), + })), + _ => ArgMatchResult::None, + } + } +} + +subcommand_invocation! { + fn run(&self, command: &CommandInvocation) -> Fallible<()> { + let mut replica = command.get_replica(); + let task = shared::get_task(&mut replica, &self.task)?; + task.into_mut(&mut replica).stop()?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn parse_command() { + with_subcommand_invocation!(vec!["task", "stop", "1"], |inv: &Invocation| { + assert_eq!(inv.task, "1".to_string()); + }); + } +} diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 5727237dc..d53715bbd 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -30,6 +30,7 @@ The following keys, and key formats, are defined: * `status` - one of `P` for a pending task (the default), `C` for completed or `D` for deleted * `description` - the one-line summary of the task * `modified` - the time of the last modification of this task +* `start.` - either an empty string (representing work on the task to the task that has not been stopped) or a timestamp (representing the time that work stopped) The following are not yet implemented: diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 332eb57b1..a5569161c 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -118,6 +118,10 @@ impl Task { &self.uuid } + pub fn get_taskmap(&self) -> &TaskMap { + &self.taskmap + } + /// Prepare to mutate this task, requiring a mutable Replica /// in order to update the data it contains. pub fn into_mut(self, replica: &mut Replica) -> TaskMut { @@ -142,6 +146,16 @@ impl Task { .unwrap_or("") } + /// Determine whether this task is active -- that is, that it has been started + /// and not stopped. + pub fn is_active(&self) -> bool { + self.taskmap + .iter() + .filter(|(k, v)| k.starts_with("start.") && v.is_empty()) + .next() + .is_some() + } + pub fn get_modified(&self) -> Option> { self.get_timestamp("modified") } @@ -184,6 +198,33 @@ impl<'r> TaskMut<'r> { self.set_timestamp("modified", Some(modified)) } + /// Start the task by creating "start. Fallible<()> { + if self.is_active() { + return Ok(()); + } + let k = format!("start.{}", Utc::now().timestamp()); + self.set_string(k.as_ref(), Some(String::from(""))) + } + + /// Stop the task by adding the current timestamp to all un-resolved "start." keys. + pub fn stop(&mut self) -> Fallible<()> { + let keys = self + .taskmap + .iter() + .filter(|(k, v)| k.starts_with("start.") && v.is_empty()) + .map(|(k, _)| k) + .cloned() + .collect::>(); + let now = Utc::now(); + for key in keys { + println!("{}", key); + self.set_timestamp(&key, Some(now))?; + } + Ok(()) + } + // -- utility functions fn lastmod(&mut self) -> Fallible<()> { @@ -212,11 +253,26 @@ impl<'r> TaskMut<'r> { fn set_timestamp(&mut self, property: &str, value: Option>) -> Fallible<()> { self.lastmod()?; - self.replica.update_task( - self.task.uuid, - property, - value.map(|v| format!("{}", v.timestamp())), - ) + if let Some(value) = value { + let ts = format!("{}", value.timestamp()); + self.replica + .update_task(self.task.uuid, property, Some(ts.clone()))?; + self.task.taskmap.insert(property.to_string(), ts); + } else { + self.replica + .update_task::<_, &str>(self.task.uuid, property, None)?; + self.task.taskmap.remove(property); + } + Ok(()) + } + + /// Used by tests to ensure that updates are properly written + #[cfg(test)] + fn reload(&mut self) -> Fallible<()> { + let uuid = self.uuid; + let task = self.replica.get_task(&uuid)?.unwrap(); + self.task.taskmap = task.taskmap; + Ok(()) } } @@ -232,6 +288,132 @@ impl<'r> std::ops::Deref for TaskMut<'r> { mod test { use super::*; + fn with_mut_task(f: F) { + let mut replica = Replica::new_inmemory(); + let task = replica.new_task(Status::Pending, "test".into()).unwrap(); + let task = task.into_mut(&mut replica); + f(task) + } + + #[test] + fn test_is_active_never_started() { + let task = Task::new(Uuid::new_v4(), TaskMap::new()); + assert!(!task.is_active()); + } + + #[test] + fn test_is_active() { + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("start.1234"), String::from(""))] + .drain(..) + .collect(), + ); + + assert!(task.is_active()); + } + + #[test] + fn test_is_active_stopped() { + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("start.1234"), String::from("1235"))] + .drain(..) + .collect(), + ); + + assert!(!task.is_active()); + } + + fn count_taskmap(task: &TaskMut, f: fn(&(&String, &String)) -> bool) -> usize { + task.taskmap.iter().filter(f).count() + } + + #[test] + fn test_start() { + with_mut_task(|mut task| { + task.start().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 1 + ); + task.reload().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 1 + ); + + // second start doesn't change anything.. + task.start().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 1 + ); + task.reload().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 1 + ); + }); + } + + #[test] + fn test_stop() { + with_mut_task(|mut task| { + task.start().unwrap(); + task.stop().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 0 + ); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && !v.is_empty()), + 1 + ); + task.reload().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 0 + ); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && !v.is_empty()), + 1 + ); + }); + } + + #[test] + fn test_stop_multiple() { + with_mut_task(|mut task| { + // simulate a task that has (through the synchronization process) been started twice + task.task + .taskmap + .insert(String::from("start.1234"), String::from("")); + task.task + .taskmap + .insert(String::from("start.5678"), String::from("")); + + task.stop().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 0 + ); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && !v.is_empty()), + 2 + ); + task.reload().unwrap(); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && v.is_empty()), + 0 + ); + assert_eq!( + count_taskmap(&task, |(k, v)| k.starts_with("start.") && !v.is_empty()), + 2 + ); + }); + } + #[test] fn test_priority() { assert_eq!(Priority::L.to_taskmap(), "L");