1use crate::error::{BallError, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha1::{Digest, Sha1};
6use std::collections::BTreeMap;
7use std::fmt;
8
9pub use crate::link::{Link, LinkType};
13
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskType {
18 Epic,
19 Task,
20 Bug,
21}
22
23impl TaskType {
24 pub fn parse(s: &str) -> Result<Self> {
25 match s {
26 "epic" => Ok(TaskType::Epic),
27 "task" => Ok(TaskType::Task),
28 "bug" => Ok(TaskType::Bug),
29 _ => Err(BallError::InvalidTask(format!("unknown type: {s}"))),
30 }
31 }
32}
33
34impl fmt::Display for TaskType {
35 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
36 f.write_str(match self {
37 TaskType::Epic => "epic",
38 TaskType::Task => "task",
39 TaskType::Bug => "bug",
40 })
41 }
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum Status {
48 Open,
49 InProgress,
50 Review,
51 Blocked,
52 Closed,
53 Deferred,
54}
55
56impl Status {
57 pub fn parse(s: &str) -> Result<Self> {
58 match s {
59 "open" => Ok(Status::Open),
60 "in_progress" => Ok(Status::InProgress),
61 "review" => Ok(Status::Review),
62 "blocked" => Ok(Status::Blocked),
63 "closed" => Ok(Status::Closed),
64 "deferred" => Ok(Status::Deferred),
65 _ => Err(BallError::InvalidTask(format!("unknown status: {s}"))),
66 }
67 }
68
69 pub fn precedence(&self) -> u8 {
70 match self {
71 Status::Closed => 6,
72 Status::Review => 5,
73 Status::InProgress => 4,
74 Status::Blocked => 3,
75 Status::Open => 2,
76 Status::Deferred => 1,
77 }
78 }
79
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 Status::Open => "open",
83 Status::InProgress => "in_progress",
84 Status::Review => "review",
85 Status::Blocked => "blocked",
86 Status::Closed => "closed",
87 Status::Deferred => "deferred",
88 }
89 }
90}
91
92impl fmt::Display for Status {
93 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
94 f.write_str(self.as_str())
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Note {
101 pub ts: DateTime<Utc>,
102 pub author: String,
103 pub text: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ArchivedChild {
108 pub id: String,
109 pub title: String,
110 pub closed_at: DateTime<Utc>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Task {
115 pub id: String,
116 pub title: String,
117 #[serde(rename = "type")]
118 pub task_type: TaskType,
119 pub priority: u8,
120 pub status: Status,
121 pub parent: Option<String>,
122 #[serde(default)]
123 pub depends_on: Vec<String>,
124 #[serde(default)]
125 pub description: String,
126 pub created_at: DateTime<Utc>,
127 pub updated_at: DateTime<Utc>,
128 pub closed_at: Option<DateTime<Utc>>,
129 pub claimed_by: Option<String>,
130 pub branch: Option<String>,
131 #[serde(default)]
132 pub tags: Vec<String>,
133 #[serde(default)]
134 pub notes: Vec<Note>,
135 #[serde(default)]
136 pub links: Vec<Link>,
137 #[serde(default)]
138 pub closed_children: Vec<ArchivedChild>,
139 #[serde(default)]
140 pub external: BTreeMap<String, Value>,
141 #[serde(default)]
145 pub delivered_in: Option<String>,
146}
147
148pub struct NewTaskOpts {
149 pub title: String,
150 pub task_type: TaskType,
151 pub priority: u8,
152 pub parent: Option<String>,
153 pub depends_on: Vec<String>,
154 pub description: String,
155 pub tags: Vec<String>,
156}
157
158impl Default for NewTaskOpts {
159 fn default() -> Self {
160 NewTaskOpts {
161 title: String::new(),
162 task_type: TaskType::Task,
163 priority: 3,
164 parent: None,
165 depends_on: Vec::new(),
166 description: String::new(),
167 tags: Vec::new(),
168 }
169 }
170}
171
172pub fn validate_id(id: &str) -> Result<()> {
175 let valid = id.starts_with("bl-")
176 && id.len() > 3
177 && id[3..].chars().all(|c| c.is_ascii_hexdigit());
178 if !valid {
179 return Err(BallError::InvalidTask(format!("invalid task id: {id}")));
180 }
181 Ok(())
182}
183
184impl Task {
185 pub fn generate_id(title: &str, ts: DateTime<Utc>, id_length: usize) -> String {
186 let mut hasher = Sha1::new();
187 hasher.update(title.as_bytes());
188 hasher.update(ts.to_rfc3339().as_bytes());
189 let digest = hasher.finalize();
190 let hex = hex::encode(digest);
191 format!("bl-{}", &hex[..id_length])
192 }
193
194 pub fn new(opts: NewTaskOpts, id: String) -> Self {
195 let now = Utc::now();
196 Task {
197 id,
198 title: opts.title,
199 task_type: opts.task_type,
200 priority: opts.priority,
201 status: Status::Open,
202 parent: opts.parent,
203 depends_on: opts.depends_on,
204 description: opts.description,
205 created_at: now,
206 updated_at: now,
207 closed_at: None,
208 claimed_by: None,
209 branch: None,
210 tags: opts.tags,
211 notes: Vec::new(),
212 links: Vec::new(),
213 closed_children: Vec::new(),
214 external: BTreeMap::new(),
215 delivered_in: None,
216 }
217 }
218
219 pub fn touch(&mut self) {
223 self.updated_at = Utc::now();
224 }
225
226 pub fn append_note(&mut self, author: &str, text: &str) {
230 self.notes.push(Note {
231 ts: Utc::now(),
232 author: author.to_string(),
233 text: text.to_string(),
234 });
235 }
236}
237
238#[cfg(test)]
239#[path = "task_tests.rs"]
240mod tests;