rusty_beads/types/
issue.rs

1//! Issue type definition - the central entity in Beads.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use super::{AgentState, IssueType, MolType, Status};
8
9/// The central entity representing a trackable work item.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Issue {
12    /// Unique identifier (format: bd-xxxx or bd-xxxx.n for children).
13    pub id: String,
14
15    /// SHA256 hash of canonical content for deduplication.
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub content_hash: Option<String>,
18
19    // === Content fields ===
20    /// Work item name (max 500 chars).
21    pub title: String,
22
23    /// Detailed explanation.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26
27    /// Design specifications.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub design: Option<String>,
30
31    /// Definition of done.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub acceptance_criteria: Option<String>,
34
35    /// Additional notes.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub notes: Option<String>,
38
39    // === Status & Workflow ===
40    /// Current workflow state.
41    #[serde(default)]
42    pub status: Status,
43
44    /// Numeric priority (0-4, lower is higher priority).
45    #[serde(default)]
46    pub priority: i32,
47
48    /// Type of work.
49    #[serde(default)]
50    pub issue_type: IssueType,
51
52    // === Assignment ===
53    /// Primary worker.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub assignee: Option<String>,
56
57    /// Responsible party.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub owner: Option<String>,
60
61    /// Time estimate in minutes.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub estimated_minutes: Option<i32>,
64
65    // === Timestamps ===
66    /// When the issue was created.
67    pub created_at: DateTime<Utc>,
68
69    /// Who created the issue.
70    pub created_by: String,
71
72    /// When the issue was last modified.
73    pub updated_at: DateTime<Utc>,
74
75    /// When the issue was closed.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub closed_at: Option<DateTime<Utc>>,
78
79    /// Why the issue was closed.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub close_reason: Option<String>,
82
83    // === Scheduling ===
84    /// Deadline.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub due_at: Option<DateTime<Utc>>,
87
88    /// Postpone until date.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub defer_until: Option<DateTime<Utc>>,
91
92    // === External Integration ===
93    /// Reference to external system (Linear, Jira, etc.).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub external_ref: Option<String>,
96
97    /// Origin system identifier.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub source_system: Option<String>,
100
101    // === Labels ===
102    /// Associated tags.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub labels: Vec<String>,
105
106    // === Soft Delete ===
107    /// When the issue was soft-deleted.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub deleted_at: Option<DateTime<Utc>>,
110
111    /// Who deleted the issue.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub deleted_by: Option<String>,
114
115    /// Why the issue was deleted.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub delete_reason: Option<String>,
118
119    // === Compaction ===
120    /// Level of compaction applied (0 = none).
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub compaction_level: Option<i32>,
123
124    /// When the issue was compacted.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub compacted_at: Option<DateTime<Utc>>,
127
128    /// Git commit at time of compaction.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub compacted_at_commit: Option<String>,
131
132    /// Original size before compaction.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub original_size: Option<i32>,
135
136    // === Agent Fields ===
137    /// Self-reported agent state.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub agent_state: Option<AgentState>,
140
141    /// Type of molecule.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub mol_type: Option<MolType>,
144
145    /// Hook bead reference.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub hook_bead: Option<String>,
148
149    /// Role bead reference.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub role_bead: Option<String>,
152
153    /// Rig reference.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub rig: Option<String>,
156
157    /// Last activity timestamp.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub last_activity: Option<DateTime<Utc>>,
160
161    // === Flags ===
162    /// Whether this issue is pinned.
163    #[serde(default)]
164    pub pinned: bool,
165
166    /// Whether this issue is a template.
167    #[serde(default)]
168    pub is_template: bool,
169
170    /// Whether this is ephemeral (not persisted).
171    #[serde(default)]
172    pub ephemeral: bool,
173}
174
175impl Issue {
176    /// Create a new issue with minimal required fields.
177    pub fn new(id: impl Into<String>, title: impl Into<String>, created_by: impl Into<String>) -> Self {
178        let now = Utc::now();
179        Self {
180            id: id.into(),
181            content_hash: None,
182            title: title.into(),
183            description: None,
184            design: None,
185            acceptance_criteria: None,
186            notes: None,
187            status: Status::Open,
188            priority: 2,
189            issue_type: IssueType::Task,
190            assignee: None,
191            owner: None,
192            estimated_minutes: None,
193            created_at: now,
194            created_by: created_by.into(),
195            updated_at: now,
196            closed_at: None,
197            close_reason: None,
198            due_at: None,
199            defer_until: None,
200            external_ref: None,
201            source_system: None,
202            labels: Vec::new(),
203            deleted_at: None,
204            deleted_by: None,
205            delete_reason: None,
206            compaction_level: None,
207            compacted_at: None,
208            compacted_at_commit: None,
209            original_size: None,
210            agent_state: None,
211            mol_type: None,
212            hook_bead: None,
213            role_bead: None,
214            rig: None,
215            last_activity: None,
216            pinned: false,
217            is_template: false,
218            ephemeral: false,
219        }
220    }
221
222    /// Compute the content hash for this issue.
223    pub fn compute_content_hash(&self) -> String {
224        let mut hasher = Sha256::new();
225
226        // Hash canonical content fields
227        hasher.update(self.title.as_bytes());
228        if let Some(ref desc) = self.description {
229            hasher.update(desc.as_bytes());
230        }
231        if let Some(ref design) = self.design {
232            hasher.update(design.as_bytes());
233        }
234        if let Some(ref ac) = self.acceptance_criteria {
235            hasher.update(ac.as_bytes());
236        }
237        if let Some(ref notes) = self.notes {
238            hasher.update(notes.as_bytes());
239        }
240
241        hex::encode(hasher.finalize())
242    }
243
244    /// Update the content hash.
245    pub fn update_content_hash(&mut self) {
246        self.content_hash = Some(self.compute_content_hash());
247    }
248
249    /// Mark this issue as updated.
250    pub fn touch(&mut self) {
251        self.updated_at = Utc::now();
252    }
253
254    /// Close this issue.
255    pub fn close(&mut self, reason: Option<String>) {
256        self.status = Status::Closed;
257        self.closed_at = Some(Utc::now());
258        self.close_reason = reason;
259        self.touch();
260    }
261
262    /// Soft-delete this issue (tombstone).
263    pub fn tombstone(&mut self, actor: &str, reason: Option<String>) {
264        self.status = Status::Tombstone;
265        self.deleted_at = Some(Utc::now());
266        self.deleted_by = Some(actor.to_string());
267        self.delete_reason = reason;
268        self.touch();
269    }
270
271    /// Returns true if this issue is soft-deleted.
272    pub fn is_deleted(&self) -> bool {
273        self.deleted_at.is_some() || self.status == Status::Tombstone
274    }
275
276    /// Returns true if this issue is ready for work (no blocking dependencies).
277    /// Note: This only checks the issue's own status; dependency checking is done in storage.
278    pub fn is_potentially_ready(&self) -> bool {
279        matches!(self.status, Status::Open) && !self.is_deleted()
280    }
281
282    /// Returns the parent ID if this is a child issue (bd-xxxx.n format).
283    pub fn parent_id(&self) -> Option<&str> {
284        self.id.rsplit_once('.').map(|(parent, _)| parent)
285    }
286
287    /// Returns true if this issue has a parent.
288    pub fn has_parent(&self) -> bool {
289        self.id.contains('.')
290    }
291
292    /// Builder method to set description.
293    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
294        self.description = Some(desc.into());
295        self
296    }
297
298    /// Builder method to set issue type.
299    pub fn with_type(mut self, issue_type: IssueType) -> Self {
300        self.issue_type = issue_type;
301        self
302    }
303
304    /// Builder method to set priority.
305    pub fn with_priority(mut self, priority: i32) -> Self {
306        self.priority = priority.clamp(0, 4);
307        self
308    }
309
310    /// Builder method to set assignee.
311    pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
312        self.assignee = Some(assignee.into());
313        self
314    }
315
316    /// Builder method to add a label.
317    pub fn with_label(mut self, label: impl Into<String>) -> Self {
318        self.labels.push(label.into());
319        self
320    }
321}
322
323impl Default for Issue {
324    fn default() -> Self {
325        Self::new("", "", "unknown")
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_issue_creation() {
335        let issue = Issue::new("bd-a1b2", "Test task", "alice");
336        assert_eq!(issue.id, "bd-a1b2");
337        assert_eq!(issue.title, "Test task");
338        assert_eq!(issue.created_by, "alice");
339        assert_eq!(issue.status, Status::Open);
340        assert_eq!(issue.priority, 2);
341    }
342
343    #[test]
344    fn test_content_hash() {
345        let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
346        issue.description = Some("Description".to_string());
347
348        let hash1 = issue.compute_content_hash();
349
350        issue.title = "Changed title".to_string();
351        let hash2 = issue.compute_content_hash();
352
353        assert_ne!(hash1, hash2);
354    }
355
356    #[test]
357    fn test_parent_id() {
358        let issue = Issue::new("bd-a1b2.1", "Child task", "alice");
359        assert_eq!(issue.parent_id(), Some("bd-a1b2"));
360        assert!(issue.has_parent());
361
362        let parent = Issue::new("bd-a1b2", "Parent task", "alice");
363        assert_eq!(parent.parent_id(), None);
364        assert!(!parent.has_parent());
365    }
366
367    #[test]
368    fn test_tombstone() {
369        let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
370        issue.tombstone("bob", Some("Duplicate".to_string()));
371
372        assert!(issue.is_deleted());
373        assert_eq!(issue.status, Status::Tombstone);
374        assert_eq!(issue.deleted_by.as_deref(), Some("bob"));
375        assert_eq!(issue.delete_reason.as_deref(), Some("Duplicate"));
376    }
377}