merge ot with rask
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
14
src/lib.rs
14
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<Vec<Task>> {
|
||||
Ok(tdb2::parse(filename, reader)?)
|
||||
}
|
||||
|
||||
@@ -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<S2>,
|
||||
) -> Result<(), Error>
|
||||
) -> Fallible<()>
|
||||
where
|
||||
S1: Into<String>,
|
||||
S2: Into<String>,
|
||||
|
||||
7
src/task/mod.rs
Normal file
7
src/task/mod.rs
Normal file
@@ -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::*;
|
||||
55
src/task/task.rs
Normal file
55
src/task/task.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use chrono::prelude::*;
|
||||
|
||||
pub type Timestamp = DateTime<Utc>;
|
||||
|
||||
#[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<Timestamp>,
|
||||
pub end: Option<Timestamp>,
|
||||
pub due: Option<Timestamp>,
|
||||
pub until: Option<Timestamp>,
|
||||
pub wait: Option<Timestamp>,
|
||||
pub modified: Timestamp,
|
||||
pub scheduled: Option<Timestamp>,
|
||||
pub recur: Option<String>,
|
||||
pub mask: Option<String>,
|
||||
pub imask: Option<u64>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub project: Option<String>,
|
||||
pub priority: Option<Priority>,
|
||||
pub depends: Vec<Uuid>,
|
||||
pub tags: Vec<String>,
|
||||
pub annotations: Vec<Annotation>,
|
||||
pub udas: HashMap<String, String>,
|
||||
}
|
||||
196
src/task/taskbuilder.rs
Normal file
196
src/task/taskbuilder.rs
Normal file
@@ -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<Status>,
|
||||
uuid: Option<Uuid>,
|
||||
entry: Option<Timestamp>,
|
||||
description: Option<String>,
|
||||
start: Option<Timestamp>,
|
||||
end: Option<Timestamp>,
|
||||
due: Option<Timestamp>,
|
||||
until: Option<Timestamp>,
|
||||
wait: Option<Timestamp>,
|
||||
modified: Option<Timestamp>,
|
||||
scheduled: Option<Timestamp>,
|
||||
recur: Option<String>,
|
||||
mask: Option<String>,
|
||||
imask: Option<u64>,
|
||||
parent: Option<Uuid>,
|
||||
project: Option<String>,
|
||||
priority: Option<Priority>,
|
||||
depends: Vec<Uuid>,
|
||||
tags: Vec<String>,
|
||||
annotations: Vec<Annotation>,
|
||||
udas: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Parse an "integer", allowing for occasional integers with trailing decimal zeroes
|
||||
fn parse_int<T>(value: &str) -> Result<T, <T as str::FromStr>::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<Status> {
|
||||
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<Priority> {
|
||||
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<Timestamp, <i64 as str::FromStr>::Err> {
|
||||
Ok(Utc.timestamp(parse_int::<i64>(value)?, 0))
|
||||
}
|
||||
|
||||
/// Parse depends, as a list of ,-separated UUIDs
|
||||
fn parse_depends(value: &str) -> Result<Vec<Uuid>, 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<String> {
|
||||
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::<u64>(&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::<u8>("123").unwrap(), 123u8);
|
||||
assert_eq!(parse_int::<u32>("123000000").unwrap(), 123000000u32);
|
||||
assert_eq!(parse_int::<i32>("-123000000").unwrap(), -123000000i32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_int_decimals() {
|
||||
assert_eq!(parse_int::<u8>("123.00").unwrap(), 123u8);
|
||||
assert_eq!(parse_int::<u32>("123.0000").unwrap(), 123u32);
|
||||
assert_eq!(parse_int::<i32>("-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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
244
src/tdb2/ff4.rs
Normal file
244
src/tdb2/ff4.rs
Normal file
@@ -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<String> {
|
||||
if value.len() < 4 {
|
||||
bail!("too short");
|
||||
}
|
||||
|
||||
fn nyb(c: u8) -> Fallible<u16> {
|
||||
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<String> {
|
||||
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<Task> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
25
src/tdb2/mod.rs
Normal file
25
src/tdb2/mod.rs
Normal file
@@ -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<Vec<Task>> {
|
||||
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)
|
||||
}
|
||||
201
src/tdb2/pig.rs
Normal file
201
src/tdb2/pig.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user