Skip to main content

balls/
task.rs

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
9// LinkType and Link live in `crate::link` to keep this file under the
10// 300-line cap. Re-exported here for call sites that still import from
11// `balls::task`.
12pub use crate::link::{Link, LinkType};
13
14/// Type of work item: epic (container), task (default), or bug.
15#[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/// Task lifecycle status.
45#[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/// Append-only note attached to a task.
99#[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    /// Performance hint: SHA of the squash-merge on main that
142    /// delivered this task. Ground truth is the `[id]` tag embedded
143    /// in the commit message — see `crate::delivery`.
144    #[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
172/// Validate that a task ID is safe for use in file paths.
173/// IDs must match `bl-[a-f0-9]+` — the format generated by `generate_id`.
174pub 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    // `save` and `load` live in `task_io.rs` to keep this file focused on
220    // type definitions and to localize the on-disk format in one module.
221
222    pub fn touch(&mut self) {
223        self.updated_at = Utc::now();
224    }
225
226    /// In-memory note append. **Does not persist.** For on-disk persistence,
227    /// use `task_io::append_note_to`, which writes to the append-only
228    /// sibling file and is safe for concurrent writers.
229    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;