model.rs

  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}