beads_rs/api/
mod.rs

1//! Canonical API schemas for daemon IPC and CLI `--json`.
2//!
3//! These types are the *truthful boundary*: we avoid lossy “view” structs that
4//! silently drop information. If a smaller payload is desirable, we define an
5//! explicit summary type.
6
7use serde::{Deserialize, Serialize};
8
9use crate::core::{
10    Bead, Claim, DepEdge as CoreDepEdge, Tombstone as CoreTombstone, WallClock, Workflow,
11    WriteStamp,
12};
13
14// =============================================================================
15// Daemon Info
16// =============================================================================
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DaemonInfo {
20    pub version: String,
21    pub protocol_version: u32,
22    pub pid: u32,
23}
24
25// =============================================================================
26// Status / Stats
27// =============================================================================
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum SyncWarning {
32    Fetch {
33        message: String,
34        at_wall_ms: u64,
35    },
36    Diverged {
37        local_oid: String,
38        remote_oid: String,
39        at_wall_ms: u64,
40    },
41    ForcePush {
42        previous_remote_oid: String,
43        remote_oid: String,
44        at_wall_ms: u64,
45    },
46    ClockSkew {
47        delta_ms: i64,
48        at_wall_ms: u64,
49    },
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SyncStatus {
54    pub dirty: bool,
55    pub sync_in_progress: bool,
56    pub last_sync_wall_ms: Option<u64>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub next_retry_wall_ms: Option<u64>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub next_retry_in_ms: Option<u64>,
61    pub consecutive_failures: u32,
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub warnings: Vec<SyncWarning>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct StatusSummary {
68    pub total_issues: usize,
69    pub open_issues: usize,
70    pub in_progress_issues: usize,
71    pub blocked_issues: usize,
72    pub closed_issues: usize,
73    pub ready_issues: usize,
74
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub tombstone_issues: Option<usize>,
77
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub epics_eligible_for_closure: Option<usize>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct StatusOutput {
84    pub summary: StatusSummary,
85
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub sync: Option<SyncStatus>,
88}
89
90// =============================================================================
91// Blocked / Stale
92// =============================================================================
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct BlockedIssue {
96    #[serde(flatten)]
97    pub issue: IssueSummary,
98
99    pub blocked_by_count: usize,
100    pub blocked_by: Vec<String>,
101}
102
103// =============================================================================
104// Ready
105// =============================================================================
106
107/// Ready result with summary counts for context.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ReadyResult {
110    pub issues: Vec<IssueSummary>,
111    pub blocked_count: usize,
112    pub closed_count: usize,
113}
114
115// =============================================================================
116// Epic
117// =============================================================================
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct EpicStatus {
121    pub epic: IssueSummary,
122    pub total_children: usize,
123    pub closed_children: usize,
124    pub eligible_for_close: bool,
125}
126
127// =============================================================================
128// Count
129// =============================================================================
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct CountGroup {
133    pub group: String,
134    pub count: usize,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum CountResult {
140    Simple {
141        count: usize,
142    },
143    Grouped {
144        total: usize,
145        groups: Vec<CountGroup>,
146    },
147}
148
149// =============================================================================
150// Deleted
151// =============================================================================
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct DeletedLookup {
155    pub found: bool,
156    pub id: String,
157
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub record: Option<Tombstone>,
160}
161
162// =============================================================================
163// Notes
164// =============================================================================
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Note {
168    pub id: String,
169    pub content: String,
170    pub author: String,
171    pub at: WriteStamp,
172}
173
174impl From<&crate::core::Note> for Note {
175    fn from(n: &crate::core::Note) -> Self {
176        Self {
177            id: n.id.as_str().to_string(),
178            content: n.content.clone(),
179            author: n.author.as_str().to_string(),
180            at: n.at.clone(),
181        }
182    }
183}
184
185// =============================================================================
186// Dependencies
187// =============================================================================
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct DepEdge {
191    pub from: String,
192    pub to: String,
193    pub kind: String,
194    pub created_at: WriteStamp,
195    pub created_by: String,
196    pub deleted_at: Option<WriteStamp>,
197    pub deleted_by: Option<String>,
198}
199
200impl From<&CoreDepEdge> for DepEdge {
201    fn from(edge: &CoreDepEdge) -> Self {
202        Self {
203            from: edge.key.from().as_str().to_string(),
204            to: edge.key.to().as_str().to_string(),
205            kind: edge.key.kind().as_str().to_string(),
206            created_at: edge.created.at.clone(),
207            created_by: edge.created.by.as_str().to_string(),
208            deleted_at: edge.deleted_stamp().map(|s| s.at.clone()),
209            deleted_by: edge.deleted_stamp().map(|s| s.by.as_str().to_string()),
210        }
211    }
212}
213
214// =============================================================================
215// Tombstones
216// =============================================================================
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Tombstone {
220    pub id: String,
221    pub deleted_at: WriteStamp,
222    pub deleted_by: String,
223    pub reason: Option<String>,
224}
225
226impl From<&CoreTombstone> for Tombstone {
227    fn from(t: &CoreTombstone) -> Self {
228        Self {
229            id: t.id.as_str().to_string(),
230            deleted_at: t.deleted.at.clone(),
231            deleted_by: t.deleted.by.as_str().to_string(),
232            reason: t.reason.clone(),
233        }
234    }
235}
236
237// =============================================================================
238// Issues
239// =============================================================================
240
241/// Full issue representation (includes notes).
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Issue {
244    pub id: String,
245    pub title: String,
246    pub description: String,
247    pub design: Option<String>,
248    pub acceptance_criteria: Option<String>,
249    pub status: String,
250    pub priority: u8,
251    #[serde(rename = "type")]
252    pub issue_type: String,
253    pub labels: Vec<String>,
254
255    pub assignee: Option<String>,
256    pub assignee_at: Option<WriteStamp>,
257    pub assignee_expires: Option<WallClock>,
258
259    pub created_at: WriteStamp,
260    pub created_by: String,
261    pub created_on_branch: Option<String>,
262
263    pub updated_at: WriteStamp,
264    pub updated_by: String,
265
266    pub closed_at: Option<WriteStamp>,
267    pub closed_by: Option<String>,
268    pub closed_reason: Option<String>,
269    pub closed_on_branch: Option<String>,
270
271    pub external_ref: Option<String>,
272    pub source_repo: Option<String>,
273
274    /// Optional time estimate in minutes (beads-go parity).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub estimated_minutes: Option<u32>,
277
278    pub content_hash: String,
279
280    pub notes: Vec<Note>,
281
282    /// Incoming dependencies (edges where this issue is the target).
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub deps_incoming: Vec<DepEdge>,
285
286    /// Outgoing dependencies (edges where this issue is the source).
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub deps_outgoing: Vec<DepEdge>,
289}
290
291/// Summary issue representation (no note bodies).
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct IssueSummary {
294    pub id: String,
295    pub title: String,
296    pub description: String,
297    pub design: Option<String>,
298    pub acceptance_criteria: Option<String>,
299    pub status: String,
300    pub priority: u8,
301    #[serde(rename = "type")]
302    pub issue_type: String,
303    pub labels: Vec<String>,
304
305    pub assignee: Option<String>,
306    pub assignee_expires: Option<WallClock>,
307
308    pub created_at: WriteStamp,
309    pub created_by: String,
310
311    pub updated_at: WriteStamp,
312    pub updated_by: String,
313
314    /// Optional time estimate in minutes (beads-go parity).
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub estimated_minutes: Option<u32>,
317
318    pub content_hash: String,
319
320    pub note_count: usize,
321}
322
323impl Issue {
324    pub fn from_bead(bead: &Bead) -> Self {
325        let updated = bead.updated_stamp();
326
327        let (assignee, assignee_at, assignee_expires) = match &bead.fields.claim.value {
328            Claim::Claimed { assignee, expires } => (
329                Some(assignee.as_str().to_string()),
330                Some(bead.fields.claim.stamp.at.clone()),
331                *expires,
332            ),
333            Claim::Unclaimed => (None, None, None),
334        };
335
336        let (closed_at, closed_by, closed_reason, closed_on_branch) =
337            match &bead.fields.workflow.value {
338                Workflow::Closed(c) => (
339                    Some(bead.fields.workflow.stamp.at.clone()),
340                    Some(bead.fields.workflow.stamp.by.as_str().to_string()),
341                    c.reason.clone(),
342                    c.on_branch.clone(),
343                ),
344                _ => (None, None, None, None),
345            };
346
347        let notes = bead.notes.sorted().into_iter().map(Note::from).collect();
348
349        Self {
350            id: bead.core.id.as_str().to_string(),
351            title: bead.fields.title.value.clone(),
352            description: bead.fields.description.value.clone(),
353            design: bead.fields.design.value.clone(),
354            acceptance_criteria: bead.fields.acceptance_criteria.value.clone(),
355            status: bead.fields.workflow.value.status().to_string(),
356            priority: bead.fields.priority.value.value(),
357            issue_type: bead.fields.bead_type.value.as_str().to_string(),
358            labels: bead
359                .fields
360                .labels
361                .value
362                .iter()
363                .map(|l| l.as_str().to_string())
364                .collect(),
365            assignee,
366            assignee_at,
367            assignee_expires,
368            created_at: bead.core.created().at.clone(),
369            created_by: bead.core.created().by.as_str().to_string(),
370            created_on_branch: bead.core.created_on_branch().map(|s| s.to_string()),
371            updated_at: updated.at.clone(),
372            updated_by: updated.by.as_str().to_string(),
373            closed_at,
374            closed_by,
375            closed_reason,
376            closed_on_branch,
377            external_ref: bead.fields.external_ref.value.clone(),
378            source_repo: bead.fields.source_repo.value.clone(),
379            estimated_minutes: bead.fields.estimated_minutes.value,
380            content_hash: bead.content_hash().to_hex(),
381            notes,
382            deps_incoming: Vec::new(),
383            deps_outgoing: Vec::new(),
384        }
385    }
386}
387
388impl IssueSummary {
389    pub fn from_bead(bead: &Bead) -> Self {
390        let updated = bead.updated_stamp();
391        Self {
392            id: bead.core.id.as_str().to_string(),
393            title: bead.fields.title.value.clone(),
394            description: bead.fields.description.value.clone(),
395            design: bead.fields.design.value.clone(),
396            acceptance_criteria: bead.fields.acceptance_criteria.value.clone(),
397            status: bead.fields.workflow.value.status().to_string(),
398            priority: bead.fields.priority.value.value(),
399            issue_type: bead.fields.bead_type.value.as_str().to_string(),
400            labels: bead
401                .fields
402                .labels
403                .value
404                .iter()
405                .map(|l| l.as_str().to_string())
406                .collect(),
407            assignee: bead
408                .fields
409                .claim
410                .value
411                .assignee()
412                .map(|a| a.as_str().to_string()),
413            assignee_expires: bead.fields.claim.value.expires(),
414            created_at: bead.core.created().at.clone(),
415            created_by: bead.core.created().by.as_str().to_string(),
416            updated_at: updated.at.clone(),
417            updated_by: updated.by.as_str().to_string(),
418            estimated_minutes: bead.fields.estimated_minutes.value,
419            content_hash: bead.content_hash().to_hex(),
420            note_count: bead.notes.len(),
421        }
422    }
423
424    pub fn from_issue(issue: &Issue) -> Self {
425        Self {
426            id: issue.id.clone(),
427            title: issue.title.clone(),
428            description: issue.description.clone(),
429            design: issue.design.clone(),
430            acceptance_criteria: issue.acceptance_criteria.clone(),
431            status: issue.status.clone(),
432            priority: issue.priority,
433            issue_type: issue.issue_type.clone(),
434            labels: issue.labels.clone(),
435            assignee: issue.assignee.clone(),
436            assignee_expires: issue.assignee_expires,
437            created_at: issue.created_at.clone(),
438            created_by: issue.created_by.clone(),
439            updated_at: issue.updated_at.clone(),
440            updated_by: issue.updated_by.clone(),
441            estimated_minutes: issue.estimated_minutes,
442            content_hash: issue.content_hash.clone(),
443            note_count: issue.notes.len(),
444        }
445    }
446}