1use anyhow::{bail, Context, Result};
2use serde::Serialize;
3use std::fmt;
4use ulid::Ulid;
5
6/// Current UTC time in ISO 8601 format.
7pub fn now_utc() -> String {
8 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
9}
10
11/// Lifecycle state for a task.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Status {
15 Open,
16 InProgress,
17 Closed,
18}
19
20impl Status {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Status::Open => "open",
24 Status::InProgress => "in_progress",
25 Status::Closed => "closed",
26 }
27 }
28
29 pub fn parse(raw: &str) -> Result<Self> {
30 match raw {
31 "open" => Ok(Self::Open),
32 "in_progress" => Ok(Self::InProgress),
33 "closed" => Ok(Self::Closed),
34 _ => bail!("invalid status '{raw}'"),
35 }
36 }
37}
38
39/// Priority for task ordering.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Priority {
43 High,
44 Medium,
45 Low,
46}
47
48impl Priority {
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Priority::High => "high",
52 Priority::Medium => "medium",
53 Priority::Low => "low",
54 }
55 }
56
57 pub fn parse(raw: &str) -> Result<Self> {
58 match raw {
59 "high" => Ok(Self::High),
60 "medium" => Ok(Self::Medium),
61 "low" => Ok(Self::Low),
62 _ => bail!("invalid priority '{raw}'"),
63 }
64 }
65
66 pub fn score(self) -> i32 {
67 match self {
68 Priority::High => 1,
69 Priority::Medium => 2,
70 Priority::Low => 3,
71 }
72 }
73}
74
75/// Estimated effort for a task.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum Effort {
79 Low,
80 Medium,
81 High,
82}
83
84impl Effort {
85 pub fn as_str(self) -> &'static str {
86 match self {
87 Effort::Low => "low",
88 Effort::Medium => "medium",
89 Effort::High => "high",
90 }
91 }
92
93 pub fn parse(raw: &str) -> Result<Self> {
94 match raw {
95 "low" => Ok(Self::Low),
96 "medium" => Ok(Self::Medium),
97 "high" => Ok(Self::High),
98 _ => bail!("invalid effort '{raw}'"),
99 }
100 }
101
102 pub fn score(self) -> i32 {
103 match self {
104 Effort::Low => 1,
105 Effort::Medium => 2,
106 Effort::High => 3,
107 }
108 }
109}
110
111/// A stable task identifier backed by a ULID.
112///
113/// Serializes as the short display form (`td-XXXXXXX`) for user-facing
114/// JSON. Use [`TaskId::as_str`] when the full ULID is needed (e.g.
115/// for CRDT keys or export round-tripping).
116#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
117pub struct TaskId(String);
118
119impl Serialize for TaskId {
120 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
121 serializer.serialize_str(&self.short())
122 }
123}
124
125impl TaskId {
126 pub fn new(id: Ulid) -> Self {
127 Self(id.to_string())
128 }
129
130 pub fn parse(raw: &str) -> Result<Self> {
131 let id = Ulid::from_string(raw).with_context(|| format!("invalid task id '{raw}'"))?;
132 Ok(Self::new(id))
133 }
134
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138
139 pub fn short(&self) -> String {
140 format!("td-{}", &self.0[self.0.len() - 7..])
141 }
142
143 /// Return a display-friendly short ID from a raw ULID string.
144 pub fn display_id(raw: &str) -> String {
145 let n = raw.len();
146 if n > 7 {
147 format!("td-{}", &raw[n - 7..])
148 } else {
149 format!("td-{raw}")
150 }
151 }
152}
153
154impl fmt::Display for TaskId {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 write!(f, "{}", self.short())
157 }
158}
159
160/// A task log entry embedded in a task record.
161#[derive(Debug, Clone, Serialize)]
162pub struct LogEntry {
163 pub id: TaskId,
164 pub timestamp: String,
165 pub message: String,
166}
167
168/// Hydrated task data from the CRDT document.
169#[derive(Debug, Clone, Serialize)]
170pub struct Task {
171 pub id: TaskId,
172 pub title: String,
173 pub description: String,
174 #[serde(rename = "type")]
175 pub task_type: String,
176 pub priority: Priority,
177 pub status: Status,
178 pub effort: Effort,
179 pub parent: Option<TaskId>,
180 pub created_at: String,
181 pub updated_at: String,
182 pub deleted_at: Option<String>,
183 pub labels: Vec<String>,
184 pub blockers: Vec<TaskId>,
185 pub logs: Vec<LogEntry>,
186}
187
188impl Task {
189 /// Serialize this task with full ULIDs instead of short display IDs.
190 ///
191 /// Used by `export` so that `import` can round-trip data losslessly —
192 /// `import` needs the full ULID to recreate exact CRDT keys.
193 pub fn to_export_value(&self) -> serde_json::Value {
194 serde_json::json!({
195 "id": self.id.as_str(),
196 "title": self.title,
197 "description": self.description,
198 "type": self.task_type,
199 "priority": self.priority,
200 "status": self.status,
201 "effort": self.effort,
202 "parent": self.parent.as_ref().map(|p| p.as_str()),
203 "created_at": self.created_at,
204 "updated_at": self.updated_at,
205 "deleted_at": self.deleted_at,
206 "labels": self.labels,
207 "blockers": self.blockers.iter().map(|b| b.as_str()).collect::<Vec<_>>(),
208 "logs": self
209 .logs
210 .iter()
211 .map(|l| {
212 serde_json::json!({
213 "id": l.id.as_str(),
214 "timestamp": l.timestamp,
215 "message": l.message,
216 })
217 })
218 .collect::<Vec<_>>(),
219 })
220 }
221}
222
223/// Result type for partitioning blockers by task state.
224#[derive(Debug, Default, Clone, Serialize)]
225pub struct BlockerPartition {
226 pub open: Vec<TaskId>,
227 pub resolved: Vec<TaskId>,
228}
229
230/// Generate a new task ULID.
231pub fn gen_id() -> TaskId {
232 TaskId::new(Ulid::new())
233}