use crate::error::{BallError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use sha1::{Digest, Sha1};
use std::collections::BTreeMap;
use std::fmt;
pub use crate::link::{Link, LinkType};
pub use crate::task_type::TaskType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Open,
InProgress,
Review,
Blocked,
Closed,
Deferred,
Unknown(String),
}
impl Status {
pub fn parse(s: &str) -> Result<Self> {
match s {
"open" => Ok(Status::Open),
"in_progress" => Ok(Status::InProgress),
"review" => Ok(Status::Review),
"blocked" => Ok(Status::Blocked),
"closed" => Ok(Status::Closed),
"deferred" => Ok(Status::Deferred),
_ => Err(BallError::InvalidTask(format!("unknown status: {s}"))),
}
}
pub fn precedence(&self) -> u8 {
match self {
Status::Closed => 6,
Status::Review => 5,
Status::InProgress => 4,
Status::Blocked => 3,
Status::Open => 2,
Status::Deferred => 1,
Status::Unknown(_) => 0,
}
}
pub fn as_str(&self) -> &str {
match self {
Status::Open => "open",
Status::InProgress => "in_progress",
Status::Review => "review",
Status::Blocked => "blocked",
Status::Closed => "closed",
Status::Deferred => "deferred",
Status::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for Status {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Status {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(match s.as_str() {
"open" => Status::Open,
"in_progress" => Status::InProgress,
"review" => Status::Review,
"blocked" => Status::Blocked,
"closed" => Status::Closed,
"deferred" => Status::Deferred,
_ => Status::Unknown(s),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub ts: DateTime<Utc>,
pub author: String,
pub text: String,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivedChild {
pub id: String,
pub title: String,
pub closed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
#[serde(rename = "type")]
pub task_type: TaskType,
pub priority: u8,
pub status: Status,
pub parent: Option<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub description: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
pub claimed_by: Option<String>,
pub branch: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub notes: Vec<Note>,
#[serde(default)]
pub links: Vec<Link>,
#[serde(default)]
pub closed_children: Vec<ArchivedChild>,
#[serde(default)]
pub external: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub synced_at: BTreeMap<String, DateTime<Utc>>,
#[serde(default)]
pub delivered_in: Option<String>,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
pub struct NewTaskOpts {
pub title: String,
pub task_type: TaskType,
pub priority: u8,
pub parent: Option<String>,
pub depends_on: Vec<String>,
pub description: String,
pub tags: Vec<String>,
}
impl Default for NewTaskOpts {
fn default() -> Self {
NewTaskOpts {
title: String::new(),
task_type: TaskType::Task,
priority: 3,
parent: None,
depends_on: Vec::new(),
description: String::new(),
tags: Vec::new(),
}
}
}
pub const PRIORITY_MIN: u8 = 1;
pub const PRIORITY_MAX: u8 = 4;
pub fn validate_priority(p: u8) -> Result<()> {
if !(PRIORITY_MIN..=PRIORITY_MAX).contains(&p) {
return Err(BallError::InvalidTask(format!(
"priority must be {PRIORITY_MIN}..={PRIORITY_MAX}"
)));
}
Ok(())
}
pub fn parse_priority(s: &str) -> Result<u8> {
let p: u8 = s
.parse()
.map_err(|_| BallError::InvalidTask(format!("priority not integer: {s}")))?;
validate_priority(p)?;
Ok(p)
}
pub fn validate_id(id: &str) -> Result<()> {
let valid = id.starts_with("bl-")
&& id.len() > 3
&& id[3..].chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return Err(BallError::InvalidTask(format!("invalid task id: {id}")));
}
Ok(())
}
impl Task {
pub fn generate_id(title: &str, ts: DateTime<Utc>, id_length: usize) -> String {
let mut hasher = Sha1::new();
hasher.update(title.as_bytes());
hasher.update(ts.to_rfc3339().as_bytes());
let digest = hasher.finalize();
let hex = hex::encode(digest);
format!("bl-{}", &hex[..id_length])
}
pub fn new(opts: NewTaskOpts, id: String) -> Self {
let now = Utc::now();
Task {
id,
title: opts.title,
task_type: opts.task_type,
priority: opts.priority,
status: Status::Open,
parent: opts.parent,
depends_on: opts.depends_on,
description: opts.description,
created_at: now,
updated_at: now,
closed_at: None,
claimed_by: None,
branch: None,
tags: opts.tags,
notes: Vec::new(),
links: Vec::new(),
closed_children: Vec::new(),
external: BTreeMap::new(),
synced_at: BTreeMap::new(),
delivered_in: None,
extra: BTreeMap::new(),
}
}
pub fn touch(&mut self) {
self.updated_at = Utc::now();
}
pub fn append_note(&mut self, author: &str, text: &str) {
self.notes.push(Note {
ts: Utc::now(),
author: author.to_string(),
text: text.to_string(),
extra: BTreeMap::new(),
});
}
}
#[cfg(test)]
#[path = "task_tests.rs"]
mod tests;