diff --git a/.taskcluster.yml b/.taskcluster.yml new file mode 100644 index 000000000..d6406c4ac --- /dev/null +++ b/.taskcluster.yml @@ -0,0 +1,29 @@ +version: 0 +tasks: + - provisionerId: '{{ taskcluster.docker.provisionerId }}' + workerType: '{{ taskcluster.docker.workerType }}' + extra: + github: + events: + - pull_request.opened + - pull_request.reopened + - pull_request.synchronize + - push + payload: + maxRunTime: 3600 + image: 'rust:latest' + command: + - /bin/bash + - '-c' + - >- + git clone {{event.head.repo.url}} repo && + cd repo && + git config advice.detachedHead false && + git checkout {{event.head.sha}} && + cargo test + metadata: + name: Test + description: 'Run tests' + owner: '{{ event.head.user.email }}' + source: '{{ event.head.repo.url }}' +allowPullRequests: collaborators diff --git a/Cargo.lock b/Cargo.lock index e2fd2d1fd..4a14bb43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,9 +130,9 @@ name = "failure_derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.12 (registry+https://github.com/rust-lang/crates.io-index)", "synstructure 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -188,19 +188,6 @@ dependencies = [ "autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "ot" -version = "0.1.0" -dependencies = [ - "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", - "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", - "failure 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", - "proptest 0.9.4 (registry+https://github.com/rust-lang/crates.io-index)", - "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", - "serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)", - "uuid 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "ppv-lite86" version = "0.2.6" @@ -208,7 +195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "proc-macro2" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -243,7 +230,7 @@ name = "quote" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -379,6 +366,19 @@ dependencies = [ "rand_core 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "rask" +version = "0.1.0" +dependencies = [ + "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", + "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", + "failure 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proptest 0.9.4 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)", + "uuid 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -439,9 +439,9 @@ name = "serde_derive" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.12 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -461,10 +461,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "syn" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -474,9 +474,9 @@ name = "synstructure" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)", "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.12 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -594,7 +594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum num-integer 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)" = "b85e541ef8255f6cf42bbfe4ef361305c6c135d10919ecc26126c4e5ae94bc09" "checksum num-traits 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)" = "d4c81ffc11c212fa327657cb19dd85eb7419e163b5b076bede2bdb5c974c07e4" "checksum ppv-lite86 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" -"checksum proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)" = "9c9e470a8dc4aeae2dee2f335e8f533e2d4b347e1434e5671afc49b054592f27" +"checksum proc-macro2 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)" = "0319972dcae462681daf4da1adeeaa066e3ebd29c69be96c6abb1259d2ee2bcc" "checksum proptest 0.9.4 (registry+https://github.com/rust-lang/crates.io-index)" = "cf147e022eacf0c8a054ab864914a7602618adba841d800a9a9868a5237a529f" "checksum quick-error 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "9274b940887ce9addde99c4eee6b5c44cc494b182b97e73dc8ffdcb3397fd3f0" "checksum quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" @@ -623,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum serde_derive 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)" = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" "checksum serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)" = "48c575e0cc52bdd09b47f330f646cf59afc586e9c4e3ccd6fc1f625b8ea1dad7" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" -"checksum syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "dff0acdb207ae2fe6d5976617f887eb1e35a2ba52c13c7234c790960cdad9238" +"checksum syn 1.0.12 (registry+https://github.com/rust-lang/crates.io-index)" = "ddc157159e2a7df58cd67b1cace10b8ed256a404fb0070593f137d8ba6bef4de" "checksum synstructure 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)" = "67656ea1dc1b41b1451851562ea232ec2e5a80242139f7e679ceccfb5d61f545" "checksum tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" "checksum textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" diff --git a/Cargo.toml b/Cargo.toml index 084a4ae26..333de8110 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,9 @@ [package] -name = "ot" +name = "rask" version = "0.1.0" authors = ["Dustin J. Mitchell "] edition = "2018" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] uuid = { version = "0.8.1", features = ["serde", "v4"] } serde = "1.0.104" diff --git a/README.md b/README.md index 95d46455f..84d7028fa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD Sketch for a Taskd Replacement ------------------------------ @@ -107,3 +108,9 @@ TBD TBD +======= +At the moment, this is sort of an make-work project to re-implement Taskwarrior in Rust. + +There's no great reason to do so, and lots of reasons not to. +But it's a nice way to practice some "basic" Rust that does not exercise all of the language's more esoteric features. +>>>>>>> 93ce28ed15d11ba601765933f95756e7fb76d2e3 diff --git a/src/bin/task.rs b/src/bin/task.rs index f83509bbf..88cff9c1e 100644 --- a/src/bin/task.rs +++ b/src/bin/task.rs @@ -1,6 +1,6 @@ extern crate clap; use clap::{App, Arg, SubCommand}; -use ot::{Replica, DB}; +use rask::{Replica, DB}; use uuid::Uuid; fn main() { diff --git a/src/errors.rs b/src/errors.rs index 08884376f..314a7a0a5 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -4,4 +4,7 @@ use failure::Fail; pub enum Error { #[fail(display = "Task Database Error: {}", _0)] DBError(String), + + #[fail(display = "TDB2 Error: {}", _0)] + TDB2Error(String), } diff --git a/src/lib.rs b/src/lib.rs index 474bf3acd..8ead13f9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,27 @@ // TODO: remove this eventually when there's an API #![allow(dead_code)] +#[macro_use] +extern crate failure; + mod errors; mod operation; mod replica; mod server; +mod task; mod taskdb; +mod tdb2; pub use operation::Operation; pub use replica::Replica; pub use server::Server; +pub use task::Task; pub use taskdb::DB; + +use failure::Fallible; +use std::io::BufRead; + +// TODO: remove (artifact of merging projects) +pub fn parse(filename: &str, reader: impl BufRead) -> Fallible> { + Ok(tdb2::parse(filename, reader)?) +} diff --git a/src/replica.rs b/src/replica.rs index 44a05a984..419dbf2be 100644 --- a/src/replica.rs +++ b/src/replica.rs @@ -1,7 +1,7 @@ -use crate::errors::Error; use crate::operation::Operation; use crate::taskdb::DB; use chrono::Utc; +use failure::Fallible; use std::collections::HashMap; use uuid::Uuid; @@ -16,12 +16,12 @@ impl Replica { } /// Create a new task. The task must not already exist. - pub fn create_task(&mut self, uuid: Uuid) -> Result<(), Error> { + pub fn create_task(&mut self, uuid: Uuid) -> Fallible<()> { self.taskdb.apply(Operation::Create { uuid }) } /// Delete a task. The task must exist. - pub fn delete_task(&mut self, uuid: Uuid) -> Result<(), Error> { + pub fn delete_task(&mut self, uuid: Uuid) -> Fallible<()> { self.taskdb.apply(Operation::Delete { uuid }) } @@ -33,7 +33,7 @@ impl Replica { uuid: Uuid, property: S1, value: Option, - ) -> Result<(), Error> + ) -> Fallible<()> where S1: Into, S2: Into, diff --git a/src/task/mod.rs b/src/task/mod.rs new file mode 100644 index 000000000..6587458ba --- /dev/null +++ b/src/task/mod.rs @@ -0,0 +1,7 @@ +mod task; +mod taskbuilder; + +pub use self::taskbuilder::TaskBuilder; +pub use self::task::{Task, Priority, Status, Timestamp, Annotation}; +pub use self::task::Priority::*; +pub use self::task::Status::*; diff --git a/src/task/task.rs b/src/task/task.rs new file mode 100644 index 000000000..29e8f4925 --- /dev/null +++ b/src/task/task.rs @@ -0,0 +1,55 @@ +use std::collections::HashMap; +use uuid::Uuid; +use chrono::prelude::*; + +pub type Timestamp = DateTime; + +#[derive(Debug, PartialEq)] +pub enum Priority { + L, + M, + H, +} + +#[derive(Debug, PartialEq)] +pub enum Status { + Pending, + Completed, + Deleted, + Recurring, + Waiting, +} + +#[derive(Debug, PartialEq)] +pub struct Annotation { + pub entry: Timestamp, + pub description: String, +} + +/// A task, the fundamental business object of this tool. +/// +/// This structure is based on https://taskwarrior.org/docs/design/task.html +#[derive(Debug)] +pub struct Task { + pub status: Status, + pub uuid: Uuid, + pub entry: Timestamp, + pub description: String, + pub start: Option, + pub end: Option, + pub due: Option, + pub until: Option, + pub wait: Option, + pub modified: Timestamp, + pub scheduled: Option, + pub recur: Option, + pub mask: Option, + pub imask: Option, + pub parent: Option, + pub project: Option, + pub priority: Option, + pub depends: Vec, + pub tags: Vec, + pub annotations: Vec, + pub udas: HashMap, +} diff --git a/src/task/taskbuilder.rs b/src/task/taskbuilder.rs new file mode 100644 index 000000000..f1d03fc06 --- /dev/null +++ b/src/task/taskbuilder.rs @@ -0,0 +1,196 @@ +use crate::task::{Annotation, Priority, Status, Task, Timestamp}; +use chrono::prelude::*; +use failure::Fallible; +use std::collections::HashMap; +use std::str; +use uuid::Uuid; + +#[derive(Default)] +pub struct TaskBuilder { + status: Option, + uuid: Option, + entry: Option, + description: Option, + start: Option, + end: Option, + due: Option, + until: Option, + wait: Option, + modified: Option, + scheduled: Option, + recur: Option, + mask: Option, + imask: Option, + parent: Option, + project: Option, + priority: Option, + depends: Vec, + tags: Vec, + annotations: Vec, + udas: HashMap, +} + +/// Parse an "integer", allowing for occasional integers with trailing decimal zeroes +fn parse_int(value: &str) -> Result::Err> +where + T: str::FromStr, +{ + // some integers are rendered with following decimal zeroes + if let Some(i) = value.find('.') { + let mut nonzero = false; + for c in value[i + 1..].chars() { + if c != '0' { + nonzero = true; + break; + } + } + if !nonzero { + return value[..i].parse(); + } + } + value.parse() +} + +/// Parse a status into a Status enum value +fn parse_status(value: &str) -> Fallible { + match value { + "pending" => Ok(Status::Pending), + "completed" => Ok(Status::Completed), + "deleted" => Ok(Status::Deleted), + "recurring" => Ok(Status::Recurring), + "waiting" => Ok(Status::Waiting), + _ => Err(format_err!("invalid status {}", value)), + } +} + +/// Parse "L", "M", "H" into the Priority enum + +fn parse_priority(value: &str) -> Fallible { + match value { + "L" => Ok(Priority::L), + "M" => Ok(Priority::M), + "H" => Ok(Priority::H), + _ => Err(format_err!("invalid priority {}", value)), + } +} + +/// Parse a UNIX timestamp into a UTC DateTime +fn parse_timestamp(value: &str) -> Result::Err> { + Ok(Utc.timestamp(parse_int::(value)?, 0)) +} + +/// Parse depends, as a list of ,-separated UUIDs +fn parse_depends(value: &str) -> Result, uuid::Error> { + value.split(',').map(|s| Uuid::parse_str(s)).collect() +} + +/// Parse tags, as a list of ,-separated strings +fn parse_tags(value: &str) -> Vec { + value.split(',').map(|s| s.to_string()).collect() +} + +impl TaskBuilder { + pub fn new() -> Self { + Default::default() + } + + pub fn set(mut self, name: &str, value: String) -> Self { + const ANNOTATION_PREFIX: &str = "annotation_"; + if name.starts_with(ANNOTATION_PREFIX) { + let entry = parse_timestamp(&name[ANNOTATION_PREFIX.len()..]).unwrap(); + // TODO: sort by entry time + self.annotations.push(Annotation { + entry, + description: value.to_string(), + }); + return self; + } + match name { + "status" => self.status = Some(parse_status(&value).unwrap()), + "uuid" => self.uuid = Some(Uuid::parse_str(&value).unwrap()), + "entry" => self.entry = Some(parse_timestamp(&value).unwrap()), + "description" => self.description = Some(value), + "start" => self.start = Some(parse_timestamp(&value).unwrap()), + "end" => self.end = Some(parse_timestamp(&value).unwrap()), + "due" => self.due = Some(parse_timestamp(&value).unwrap()), + "until" => self.until = Some(parse_timestamp(&value).unwrap()), + "wait" => self.wait = Some(parse_timestamp(&value).unwrap()), + "modified" => self.modified = Some(parse_timestamp(&value).unwrap()), + "scheduled" => self.scheduled = Some(parse_timestamp(&value).unwrap()), + "recur" => self.recur = Some(value), + "mask" => self.mask = Some(value), + "imask" => self.imask = Some(parse_int::(&value).unwrap()), + "parent" => self.uuid = Some(Uuid::parse_str(&value).unwrap()), + "project" => self.project = Some(value), + "priority" => self.priority = Some(parse_priority(&value).unwrap()), + "depends" => self.depends = parse_depends(&value).unwrap(), + "tags" => self.tags = parse_tags(&value), + _ => { + self.udas.insert(name.to_string(), value); + } + } + self + } + + pub fn finish(self) -> Task { + Task { + status: self.status.unwrap(), + uuid: self.uuid.unwrap(), + description: self.description.unwrap(), + entry: self.entry.unwrap(), + start: self.start, + end: self.end, + due: self.due, + until: self.until, + wait: self.wait, + modified: self.modified.unwrap(), + scheduled: self.scheduled, + recur: self.recur, + mask: self.mask, + imask: self.imask, + parent: self.parent, + project: self.project, + priority: self.priority, + depends: self.depends, + tags: self.tags, + annotations: self.annotations, + udas: self.udas, + } + + // TODO: check validity per https://taskwarrior.org/docs/design/task.html + } +} + +#[cfg(test)] +mod test { + use super::{parse_depends, parse_int}; + use uuid::Uuid; + + #[test] + fn test_parse_int() { + assert_eq!(parse_int::("123").unwrap(), 123u8); + assert_eq!(parse_int::("123000000").unwrap(), 123000000u32); + assert_eq!(parse_int::("-123000000").unwrap(), -123000000i32); + } + + #[test] + fn test_parse_int_decimals() { + assert_eq!(parse_int::("123.00").unwrap(), 123u8); + assert_eq!(parse_int::("123.0000").unwrap(), 123u32); + assert_eq!(parse_int::("-123.").unwrap(), -123i32); + } + + #[test] + fn test_parse_depends() { + let u1 = "123e4567-e89b-12d3-a456-426655440000"; + let u2 = "123e4567-e89b-12d3-a456-999999990000"; + assert_eq!( + parse_depends(u1).unwrap(), + vec![Uuid::parse_str(u1).unwrap()] + ); + assert_eq!( + parse_depends(&format!("{},{}", u1, u2)).unwrap(), + vec![Uuid::parse_str(u1).unwrap(), Uuid::parse_str(u2).unwrap()] + ); + } +} diff --git a/src/taskdb.rs b/src/taskdb.rs index 34506450d..103b9f4e6 100644 --- a/src/taskdb.rs +++ b/src/taskdb.rs @@ -1,6 +1,7 @@ use crate::errors::Error; use crate::operation::Operation; use crate::server::{Server, VersionAdd}; +use failure::Fallible; use serde::{Deserialize, Serialize}; use std::collections::hash_map::Entry; use std::collections::HashMap; @@ -42,7 +43,7 @@ impl DB { /// Apply an operation to the DB. Aside from synchronization operations, this is the only way /// to modify the DB. In cases where an operation does not make sense, this function will do /// nothing and return an error (but leave the DB in a consistent state). - pub fn apply(&mut self, op: Operation) -> Result<(), Error> { + pub fn apply(&mut self, op: Operation) -> Fallible<()> { if let err @ Err(_) = self.apply_op(&op) { return err; } @@ -50,19 +51,19 @@ impl DB { Ok(()) } - fn apply_op(&mut self, op: &Operation) -> Result<(), Error> { + fn apply_op(&mut self, op: &Operation) -> Fallible<()> { match op { &Operation::Create { uuid } => { // insert if the task does not already exist if let ent @ Entry::Vacant(_) = self.tasks.entry(uuid) { ent.or_insert(HashMap::new()); } else { - return Err(Error::DBError(format!("Task {} already exists", uuid))); + return Err(Error::DBError(format!("Task {} already exists", uuid)).into()); } } &Operation::Delete { ref uuid } => { if let None = self.tasks.remove(uuid) { - return Err(Error::DBError(format!("Task {} does not exist", uuid))); + return Err(Error::DBError(format!("Task {} does not exist", uuid)).into()); } } &Operation::Update { @@ -78,7 +79,7 @@ impl DB { None => task.remove(property), }; } else { - return Err(Error::DBError(format!("Task {} does not exist", uuid))); + return Err(Error::DBError(format!("Task {} does not exist", uuid)).into()); } } } @@ -203,8 +204,8 @@ mod tests { let op = Operation::Create { uuid }; db.apply(op.clone()).unwrap(); assert_eq!( - db.apply(op.clone()).err().unwrap(), - Error::DBError(format!("Task {} already exists", uuid)) + db.apply(op.clone()).err().unwrap().to_string(), + format!("Task Database Error: Task {} already exists", uuid) ); let mut exp = HashMap::new(); @@ -285,8 +286,8 @@ mod tests { timestamp: Utc::now(), }; assert_eq!( - db.apply(op).err().unwrap(), - Error::DBError(format!("Task {} does not exist", uuid)) + db.apply(op).err().unwrap().to_string(), + format!("Task Database Error: Task {} does not exist", uuid) ); assert_eq!(db.tasks(), &HashMap::new()); @@ -314,8 +315,8 @@ mod tests { let op1 = Operation::Delete { uuid }; assert_eq!( - db.apply(op1).err().unwrap(), - Error::DBError(format!("Task {} does not exist", uuid)) + db.apply(op1).err().unwrap().to_string(), + format!("Task Database Error: Task {} does not exist", uuid) ); assert_eq!(db.tasks(), &HashMap::new()); diff --git a/src/tdb2/ff4.rs b/src/tdb2/ff4.rs new file mode 100644 index 000000000..9f92c63c6 --- /dev/null +++ b/src/tdb2/ff4.rs @@ -0,0 +1,244 @@ +use std::str; + +use super::pig::Pig; +use crate::task::{Task, TaskBuilder}; +use failure::Fallible; + +/// Rust implementation of part of utf8_codepoint from Taskwarrior's src/utf8.cpp +/// +/// Note that the original function will return garbage for invalid hex sequences; +/// this panics instead. +fn hex_to_unicode(value: &[u8]) -> Fallible { + if value.len() < 4 { + bail!("too short"); + } + + fn nyb(c: u8) -> Fallible { + match c { + b'0'..=b'9' => Ok((c - b'0') as u16), + b'a'..=b'f' => Ok((c - b'a' + 10) as u16), + b'A'..=b'F' => Ok((c - b'A' + 10) as u16), + _ => bail!("invalid hex character"), + } + }; + + let words = [nyb(value[0])? << 12 | nyb(value[1])? << 8 | nyb(value[2])? << 4 | nyb(value[3])?]; + Ok(String::from_utf16(&words[..])?) +} + +/// Rust implementation of JSON::decode in Taskwarrior's src/JSON.cpp +/// +/// Decode the given byte slice into a string using Taskwarrior JSON's escaping The slice is +/// assumed to be ASCII; unicode escapes within it will be expanded. +fn json_decode(value: &[u8]) -> Fallible { + let length = value.len(); + let mut rv = String::with_capacity(length); + + let mut pos = 0; + while pos < length { + let v = value[pos]; + if v == b'\\' { + pos += 1; + if pos == length { + rv.push(v as char); + break; + } + let v = value[pos]; + match v { + b'"' | b'\\' | b'/' => rv.push(v as char), + b'b' => rv.push(8 as char), + b'f' => rv.push(12 as char), + b'n' => rv.push('\n' as char), + b'r' => rv.push('\r' as char), + b't' => rv.push('\t' as char), + b'u' => { + let unicode = hex_to_unicode(&value[pos + 1..pos + 5]).map_err(|_| { + let esc = &value[pos - 1..pos + 5]; + match str::from_utf8(esc) { + Ok(s) => format_err!("invalid unicode escape `{}`", s), + Err(_) => format_err!("invalid unicode escape bytes {:?}", esc), + } + })?; + rv.push_str(&unicode); + pos += 4; + } + _ => { + rv.push(b'\\' as char); + rv.push(v as char); + } + } + } else { + rv.push(v as char) + } + pos += 1; + } + + Ok(rv) +} + +/// Rust implementation of Task::decode in Taskwarrior's src/Task.cpp +/// +/// Note that the docstring for the C++ function does not match the +/// implementation! +fn decode(value: String) -> String { + if let Some(_) = value.find('&') { + return value.replace("&open;", "[").replace("&close;", "]"); + } + value +} + +/// Parse an "FF4" formatted task line. From Task::parse in Taskwarrior's src/Task.cpp. +/// +/// While Taskwarrior supports additional formats, this is the only format supported by rask. +pub(super) fn parse_ff4(line: &str) -> Fallible { + let mut pig = Pig::new(line.as_bytes()); + let mut builder = TaskBuilder::new(); + + pig.skip(b'[')?; + let line = pig.get_until(b']')?; + let mut subpig = Pig::new(line); + while !subpig.depleted() { + let name = subpig.get_until(b':')?; + let name = str::from_utf8(name)?; + subpig.skip(b':')?; + let value = subpig.get_quoted(b'"')?; + let value = json_decode(value)?; + let value = decode(value); + builder = builder.set(name, value); + subpig.skip(b' ').ok(); // ignore if not found.. + } + pig.skip(b']')?; + if !pig.depleted() { + bail!("trailing characters on line"); + } + Ok(builder.finish()) +} + +#[cfg(test)] +mod test { + use super::{decode, hex_to_unicode, json_decode, parse_ff4}; + use crate::task::Pending; + + #[test] + fn test_hex_to_unicode_digits() { + assert_eq!(hex_to_unicode(b"1234").unwrap(), "\u{1234}"); + } + + #[test] + fn test_hex_to_unicode_lower() { + assert_eq!(hex_to_unicode(b"abcd").unwrap(), "\u{abcd}"); + } + + #[test] + fn test_hex_to_unicode_upper() { + assert_eq!(hex_to_unicode(b"ABCD").unwrap(), "\u{abcd}"); + } + + #[test] + fn test_hex_to_unicode_too_short() { + assert!(hex_to_unicode(b"AB").is_err()); + } + + #[test] + fn test_hex_to_unicode_invalid() { + assert!(hex_to_unicode(b"defg").is_err()); + } + + #[test] + fn test_json_decode_no_change() { + assert_eq!(json_decode(b"abcd").unwrap(), "abcd"); + } + + #[test] + fn test_json_decode_escape_quote() { + assert_eq!(json_decode(b"ab\\\"cd").unwrap(), "ab\"cd"); + } + + #[test] + fn test_json_decode_escape_backslash() { + assert_eq!(json_decode(b"ab\\\\cd").unwrap(), "ab\\cd"); + } + + #[test] + fn test_json_decode_escape_frontslash() { + assert_eq!(json_decode(b"ab\\/cd").unwrap(), "ab/cd"); + } + + #[test] + fn test_json_decode_escape_b() { + assert_eq!(json_decode(b"ab\\bcd").unwrap(), "ab\x08cd"); + } + + #[test] + fn test_json_decode_escape_f() { + assert_eq!(json_decode(b"ab\\fcd").unwrap(), "ab\x0ccd"); + } + + #[test] + fn test_json_decode_escape_n() { + assert_eq!(json_decode(b"ab\\ncd").unwrap(), "ab\ncd"); + } + + #[test] + fn test_json_decode_escape_r() { + assert_eq!(json_decode(b"ab\\rcd").unwrap(), "ab\rcd"); + } + + #[test] + fn test_json_decode_escape_t() { + assert_eq!(json_decode(b"ab\\tcd").unwrap(), "ab\tcd"); + } + + #[test] + fn test_json_decode_escape_other() { + assert_eq!(json_decode(b"ab\\xcd").unwrap(), "ab\\xcd"); + } + + #[test] + fn test_json_decode_escape_eos() { + assert_eq!(json_decode(b"ab\\").unwrap(), "ab\\"); + } + + #[test] + fn test_json_decode_escape_unicode() { + assert_eq!(json_decode(b"ab\\u1234").unwrap(), "ab\u{1234}"); + } + + #[test] + fn test_json_decode_escape_unicode_bad() { + let rv = json_decode(b"ab\\uwxyz"); + assert_eq!( + rv.unwrap_err().to_string(), + "invalid unicode escape `\\uwxyz`" + ); + } + + #[test] + fn test_decode_no_change() { + let s = "abcd " efgh &".to_string(); + assert_eq!(decode(s.clone()), s); + } + + #[test] + fn test_decode_multi() { + let s = "abcd &open; efgh &close; &open".to_string(); + assert_eq!(decode(s), "abcd [ efgh ] &open".to_string()); + } + + #[test] + fn test_parse_ff4() { + let s = "[description:\"desc\" entry:\"1437855511\" modified:\"1479480556\" \ + priority:\"L\" project:\"lists\" status:\"pending\" tags:\"watch\" \ + uuid:\"83ce989e-8634-4d62-841c-eb309383ff1f\"]"; + let task = parse_ff4(s).unwrap(); + assert_eq!(task.status, Pending); + assert_eq!(task.description, "desc"); + } + + #[test] + fn test_parse_ff4_fail() { + assert!(parse_ff4("abc:10]").is_err()); + assert!(parse_ff4("[abc:10").is_err()); + assert!(parse_ff4("[abc:10 123:123]").is_err()); + } +} diff --git a/src/tdb2/mod.rs b/src/tdb2/mod.rs new file mode 100644 index 000000000..7b39d986c --- /dev/null +++ b/src/tdb2/mod.rs @@ -0,0 +1,25 @@ +//! TDB2 is Taskwarrior's on-disk database format. This module implements +//! support for the data structure as a compatibility layer. + +mod ff4; +mod pig; + +use self::ff4::parse_ff4; +use crate::task::Task; +use failure::Fallible; +use std::io::BufRead; + +pub(crate) fn parse(filename: &str, reader: impl BufRead) -> Fallible> { + let mut tasks = vec![]; + for (i, line) in reader.lines().enumerate() { + tasks.push(parse_ff4(&line?).map_err(|e| { + format_err!( + "TDB2 Error at {}:{}: {}", + filename.to_string(), + i as u64 + 1, + e + ) + })?); + } + Ok(tasks) +} diff --git a/src/tdb2/pig.rs b/src/tdb2/pig.rs new file mode 100644 index 000000000..a3d837629 --- /dev/null +++ b/src/tdb2/pig.rs @@ -0,0 +1,201 @@ +//! A minimal implementation of the "Pig" parsing utility from the Taskwarrior +//! source. This is just enough to parse FF4 lines. + +use failure::Fallible; + +pub struct Pig<'a> { + input: &'a [u8], + cursor: usize, +} + +impl<'a> Pig<'a> { + pub fn new(input: &'a [u8]) -> Self { + Pig { + input: input, + cursor: 0, + } + } + + pub fn get_until(&mut self, c: u8) -> Fallible<&'a [u8]> { + if self.cursor >= self.input.len() { + bail!("input truncated"); + } + + let mut i = self.cursor; + while i < self.input.len() { + if self.input[i] == c { + let rv = &self.input[self.cursor..i]; + self.cursor = i; + return Ok(rv); + } + i += 1; + } + let rv = &self.input[self.cursor..]; + self.cursor = self.input.len(); + Ok(rv) + } + + pub fn get_quoted(&mut self, c: u8) -> Fallible<&'a [u8]> { + let length = self.input.len(); + if self.cursor >= length || self.input[self.cursor] != c { + bail!("quoted string does not begin with quote character"); + } + + let start = self.cursor + 1; + let mut i = start; + + while i < length { + while i < length && self.input[i] != c { + i += 1 + } + if i == length { + bail!("unclosed quote"); + } + if i == start { + return Ok(&self.input[i..i]); + } + + if self.input[i - 1] == b'\\' { + // work backward looking for escaped backslashes + let mut j = i - 2; + let mut quote = true; + while j >= start && self.input[j] == b'\\' { + quote = !quote; + j -= 1; + } + + if quote { + i += 1; + continue; + } + } + + // none of the above matched, so we are at the end + self.cursor = i + 1; + return Ok(&self.input[start..i]); + } + + unreachable!(); + } + + pub fn skip(&mut self, c: u8) -> Fallible<()> { + if self.cursor < self.input.len() && self.input[self.cursor] == c { + self.cursor += 1; + return Ok(()); + } + bail!( + "expected character `{}`", + String::from_utf8(vec![c]).unwrap() + ); + } + + pub fn depleted(&self) -> bool { + self.cursor >= self.input.len() + } +} + +#[cfg(test)] +mod test { + use super::Pig; + + #[test] + fn test_get_until() { + let s = b"abc:123"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_until(b':').unwrap(), &s[..3]); + } + + #[test] + fn test_get_until_empty() { + let s = b"abc:123"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_until(b'a').unwrap(), &s[..0]); + } + + #[test] + fn test_get_until_not_found() { + let s = b"abc:123"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_until(b'/').unwrap(), &s[..]); + } + + #[test] + fn test_get_quoted() { + let s = b"'abcd'efg"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_quoted(b'\'').unwrap(), &s[1..5]); + assert_eq!(pig.cursor, 6); + } + + #[test] + fn test_get_quoted_unopened() { + let s = b"abcd'efg"; + let mut pig = Pig::new(s); + assert!(pig.get_quoted(b'\'').is_err()); + assert_eq!(pig.cursor, 0); // nothing consumed + } + + #[test] + fn test_get_quoted_unclosed() { + let s = b"'abcdefg"; + let mut pig = Pig::new(s); + assert!(pig.get_quoted(b'\'').is_err()); + assert_eq!(pig.cursor, 0); + } + + #[test] + fn test_get_quoted_escaped() { + let s = b"'abc\\'de'fg"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_quoted(b'\'').unwrap(), &s[1..8]); + assert_eq!(pig.cursor, 9); + } + + #[test] + fn test_get_quoted_double_escaped() { + let s = b"'abc\\\\'de'fg"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_quoted(b'\'').unwrap(), &s[1..6]); + assert_eq!(pig.cursor, 7); + } + + #[test] + fn test_get_quoted_triple_escaped() { + let s = b"'abc\\\\\\'de'fg"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_quoted(b'\'').unwrap(), &s[1..10]); + assert_eq!(pig.cursor, 11); + } + + #[test] + fn test_get_quoted_all_escapes() { + let s = b"'\\\\\\'\\\\'fg"; + let mut pig = Pig::new(s); + assert_eq!(pig.get_quoted(b'\'').unwrap(), &s[1..7]); + assert_eq!(pig.cursor, 8); + } + + #[test] + fn test_skip_match() { + let s = b"foo"; + let mut pig = Pig::new(s); + assert!(pig.skip(b'f').is_ok()); + assert_eq!(pig.cursor, 1); + } + + #[test] + fn test_skip_no_match() { + let s = b"foo"; + let mut pig = Pig::new(s); + assert!(pig.skip(b'x').is_err()); + assert_eq!(pig.cursor, 0); // nothing consumed + } + + #[test] + fn test_skip_eos() { + let s = b"f"; + let mut pig = Pig::new(s); + assert!(pig.skip(b'f').is_ok()); + assert!(pig.skip(b'f').is_err()); + } +} diff --git a/tests/data/tdb2-test.data b/tests/data/tdb2-test.data new file mode 100644 index 000000000..60477651b --- /dev/null +++ b/tests/data/tdb2-test.data @@ -0,0 +1,2 @@ +[description:"https:\/\/phabricator.services.example.com\/D7364 &open;taskgraph&close; Download debian packages" end:"1541705209" entry:"1538520624" modified:"1541705209" phabricatorid:"D7364" priority:"M" project:"moz" status:"completed" tags:"phabricator,respond" uuid:"ca33f6d6-1688-4503-90be-3b3526a32b5a" wait:"1570118809"] +[annotation_1541461824:"https:\/\/github.com\/taskcluster\/taskcluster-worker-manager\/pull\/3" description:"https:\/\/github.com\/taskcluster\/taskcluster-worker-manager\/pull\/3 More changes" end:"1541702602" entry:"1541451283" githubbody:"some notes:\n\n1. This is a huge PR, so I'm not expecting a quick turn around at all. If you have questions, let me know and I can hope on Vidyo.\n1. Data persistence is written in a semi-janky way. My intention is to use the time while this is under review to make progress on postgres stuff so that the next review cycle will be using new Postgres things. Which means.... There's a lot of bugs in the concurrency because there's no synchronisation at all in this janky-ish model.\n1. The API is the minimum api required to get provisioning-ish things working\n1. I intend to write a system for automatically testing provider and bidding strategy implementations, so that you can do instantiate a provider\/strategy, stub\/spy it as needed then run a test suite against it and have it do its thing. The idea is that each provider will need to mock their underlying api system in their own way, but the set of tests we run for Provider API conformance would be pretty standardized. This should make writing tests for new providers a lot easier.\n1. The provider\/strategy loading system is intentionally simple. The idea is that these aren't general purpose plugins, but rather special ones. The idea is that the config files would essentially declare instances and then provide constructor arguments to initialize them all... This would make enabling\/disabling providers\/strategies fairly trivial\n1. I decided to drop fake implementations of providers and strategies for testing the provisioning logic and instead opt for Sinon stubs, which I think give us a better testing story\n1. I still intend to have fake providers and bidding strategies for doing API testing.\n\nLet me know, and again, I don't expect or need a quick turn around on this PR.\n" githubcreatedon:"1541451283" githubnamespace:"djmitche" githubnumber:"3.000000" githubrepo:"taskcluster\/taskcluster-worker-manager" githubtitle:"More changes" githubtype:"pull_request" githubupdatedat:"1541699191" githuburl:"https:\/\/github.com\/taskcluster\/taskcluster-worker-manager\/pull\/3" githubuser:"jhford" modified:"1541702602" priority:"H" project:"moz" status:"completed" tags:"respond" uuid:"2186f981-d1f5-4642-b833-5b16b3a2d334"] diff --git a/tests/operation_transform_invariant.rs b/tests/operation_transform_invariant.rs index 2f73e3a30..e0f67b9de 100644 --- a/tests/operation_transform_invariant.rs +++ b/tests/operation_transform_invariant.rs @@ -1,7 +1,6 @@ use chrono::Utc; -use ot::Operation; -use ot::DB; use proptest::prelude::*; +use rask::{Operation, DB}; use uuid::Uuid; fn uuid_strategy() -> impl Strategy { diff --git a/tests/parse.rs b/tests/parse.rs new file mode 100644 index 000000000..d054d5c6e --- /dev/null +++ b/tests/parse.rs @@ -0,0 +1,20 @@ +use chrono::prelude::*; +use std::fs::File; +use std::io::BufReader; + +#[test] +fn test_parse() { + let filename = "tests/data/tdb2-test.data"; + let file = File::open(filename).unwrap(); + let tasks = rask::parse(filename, BufReader::new(file)).unwrap(); + assert_eq!( + tasks[0].description, + "https://phabricator.services.example.com/D7364 [taskgraph] Download debian packages" + ); + assert_eq!(tasks[0].entry, Utc.timestamp(1538520624, 0)); + assert_eq!(tasks[0].udas.get("phabricatorid").unwrap(), "D7364"); + assert_eq!(tasks[1].annotations[0].entry, Utc.timestamp(1541461824, 0)); + assert!(tasks[1].annotations[0] + .description + .starts_with("https://github.com",)); +} diff --git a/tests/sync.rs b/tests/sync.rs index 733fc71ce..4055a46ee 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use ot::{Operation, Server, DB}; +use rask::{Operation, Server, DB}; use uuid::Uuid; #[test] diff --git a/tests/sync_action_sequences.rs b/tests/sync_action_sequences.rs index dd4a7e364..bba2a0631 100644 --- a/tests/sync_action_sequences.rs +++ b/tests/sync_action_sequences.rs @@ -1,6 +1,6 @@ use chrono::Utc; -use ot::{Operation, Server, DB}; use proptest::prelude::*; +use rask::{Operation, Server, DB}; use uuid::Uuid; #[derive(Debug)]