Skip to main content

beads_rust/format/
output.rs

1use crate::model::{Comment, Event, Issue, IssueType, Priority, Status};
2use chrono::{DateTime, Utc};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// Minimal issue output for stale command (bd parity).
7/// Contains only the fields that bd's stale command outputs.
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
9pub struct StaleIssue {
10    pub created_at: DateTime<Utc>,
11    pub id: String,
12    pub issue_type: IssueType,
13    pub priority: Priority,
14    pub status: Status,
15    pub title: String,
16    pub updated_at: DateTime<Utc>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub assignee: Option<String>,
19}
20
21/// Minimal issue output for ready command (bd parity).
22///
23/// Contains only the fields that bd's ready command outputs.
24/// Does NOT include: `compaction_level`, `original_size`, `dependency_count`, `dependent_count`
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26pub struct ReadyIssue {
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub acceptance_criteria: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub assignee: Option<String>,
31    pub created_at: DateTime<Utc>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub created_by: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub estimated_minutes: Option<i32>,
38    pub id: String,
39    pub issue_type: IssueType,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub notes: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub owner: Option<String>,
44    pub priority: Priority,
45    pub status: Status,
46    pub title: String,
47    pub updated_at: DateTime<Utc>,
48}
49
50impl From<Issue> for ReadyIssue {
51    fn from(issue: Issue) -> Self {
52        Self {
53            acceptance_criteria: issue.acceptance_criteria,
54            assignee: issue.assignee,
55            created_at: issue.created_at,
56            created_by: issue.created_by,
57            description: issue.description,
58            estimated_minutes: issue.estimated_minutes,
59            id: issue.id,
60            issue_type: issue.issue_type,
61            notes: issue.notes,
62            owner: issue.owner,
63            priority: issue.priority,
64            status: issue.status,
65            title: issue.title,
66            updated_at: issue.updated_at,
67        }
68    }
69}
70
71/// Minimal issue output for blocked command (bd parity).
72///
73/// Contains only the fields that bd's blocked command outputs, plus `blocked_by` info.
74/// Does NOT include: `compaction_level`, `original_size`
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76pub struct BlockedIssueOutput {
77    pub blocked_by: Vec<String>,
78    pub blocked_by_count: usize,
79    pub created_at: DateTime<Utc>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub created_by: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84    pub id: String,
85    pub issue_type: IssueType,
86    pub priority: Priority,
87    pub status: Status,
88    pub title: String,
89    pub updated_at: DateTime<Utc>,
90}
91
92impl From<Issue> for StaleIssue {
93    fn from(issue: Issue) -> Self {
94        Self {
95            created_at: issue.created_at,
96            id: issue.id,
97            issue_type: issue.issue_type,
98            priority: issue.priority,
99            status: issue.status,
100            title: issue.title,
101            updated_at: issue.updated_at,
102            assignee: issue.assignee,
103        }
104    }
105}
106
107/// Issue with counts for list/search views.
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109pub struct IssueWithCounts {
110    #[serde(flatten)]
111    pub issue: Issue,
112    pub dependency_count: usize,
113    pub dependent_count: usize,
114}
115
116/// Paginated list response envelope for `br list --json`.
117///
118/// Wraps the issue array with pagination metadata so consumers can detect
119/// truncation and iterate through all results using `--limit` / `--offset`.
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
121pub struct ListPage {
122    /// The issues in this page of results.
123    pub issues: Vec<IssueWithCounts>,
124    /// Total number of issues matching the query (ignoring LIMIT/OFFSET).
125    pub total: usize,
126    /// Maximum number of results requested (`--limit`; 0 means unlimited).
127    pub limit: usize,
128    /// Number of results skipped (`--offset`).
129    pub offset: usize,
130    /// Whether there are more results beyond this page.
131    pub has_more: bool,
132}
133
134/// Issue details with full relations for show view.
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
136pub struct IssueDetails {
137    #[serde(flatten)]
138    pub issue: Issue,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub labels: Vec<String>,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub dependencies: Vec<IssueWithDependencyMetadata>,
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub dependents: Vec<IssueWithDependencyMetadata>,
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub comments: Vec<Comment>,
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub events: Vec<Event>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub parent: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154pub struct IssueWithDependencyMetadata {
155    pub id: String,
156    pub title: String,
157    pub status: Status,
158    pub priority: Priority,
159    #[serde(rename = "dependency_type")]
160    pub dep_type: String,
161}
162
163/// Blocked issue for blocked view.
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
165pub struct BlockedIssue {
166    #[serde(flatten)]
167    pub issue: Issue,
168    pub blocked_by_count: usize,
169    pub blocked_by: Vec<String>,
170}
171
172/// Tree node for dependency tree view.
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174pub struct TreeNode {
175    #[serde(flatten)]
176    pub issue: Issue,
177    pub depth: usize,
178    pub parent_id: Option<String>,
179    pub truncated: bool,
180}
181
182/// Summary statistics for the project.
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184pub struct StatsSummary {
185    pub total_issues: usize,
186    pub open_issues: usize,
187    pub in_progress_issues: usize,
188    pub closed_issues: usize,
189    pub blocked_issues: usize,
190    pub deferred_issues: usize,
191    pub draft_issues: usize,
192    pub ready_issues: usize,
193    pub tombstone_issues: usize,
194    pub pinned_issues: usize,
195    pub epics_eligible_for_closure: usize,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub average_lead_time_hours: Option<f64>,
198}
199
200/// Breakdown statistics by a dimension.
201#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
202pub struct Breakdown {
203    pub dimension: String,
204    pub counts: Vec<BreakdownEntry>,
205}
206
207/// A single entry in a breakdown.
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
209pub struct BreakdownEntry {
210    pub key: String,
211    pub count: usize,
212}
213
214/// Recent activity statistics from git history.
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
216pub struct RecentActivity {
217    pub hours_tracked: u32,
218    pub commit_count: usize,
219    pub issues_created: usize,
220    pub issues_closed: usize,
221    pub issues_updated: usize,
222    pub issues_reopened: usize,
223    pub total_changes: usize,
224}
225
226/// Aggregate statistics output.
227#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
228pub struct Statistics {
229    pub summary: StatsSummary,
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    pub breakdowns: Vec<Breakdown>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub recent_activity: Option<RecentActivity>,
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use chrono::{TimeZone, Utc};
240
241    fn base_issue(id: &str, title: &str) -> Issue {
242        Issue {
243            id: id.to_string(),
244            content_hash: None,
245            title: title.to_string(),
246            description: None,
247            design: None,
248            acceptance_criteria: None,
249            notes: None,
250            status: Status::Open,
251            priority: Priority::MEDIUM,
252            issue_type: crate::model::IssueType::Task,
253            assignee: None,
254            owner: None,
255            estimated_minutes: None,
256            created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
257            created_by: None,
258            updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
259            closed_at: None,
260            close_reason: None,
261            closed_by_session: None,
262            due_at: None,
263            defer_until: None,
264            external_ref: None,
265            source_system: None,
266            source_repo: None,
267            deleted_at: None,
268            deleted_by: None,
269            delete_reason: None,
270            original_type: None,
271            compaction_level: None,
272            compacted_at: None,
273            compacted_at_commit: None,
274            original_size: None,
275            sender: None,
276            ephemeral: false,
277            pinned: false,
278            is_template: false,
279            labels: vec![],
280            dependencies: vec![],
281            comments: vec![],
282        }
283    }
284
285    #[test]
286    fn issue_with_counts_serializes_counts() {
287        let issue = base_issue("bd-1", "Test");
288        let iwc = IssueWithCounts {
289            issue,
290            dependency_count: 2,
291            dependent_count: 1,
292        };
293
294        let json = serde_json::to_string(&iwc).unwrap();
295        assert!(json.contains("\"dependency_count\":2"));
296        assert!(json.contains("\"dependent_count\":1"));
297        assert!(json.contains("\"id\":\"bd-1\""));
298    }
299
300    #[test]
301    fn issue_details_serializes_parent_and_relations() {
302        let issue = base_issue("bd-2", "Details");
303        let details = IssueDetails {
304            issue,
305            labels: vec!["backend".to_string()],
306            dependencies: vec![],
307            dependents: vec![],
308            comments: vec![],
309            events: vec![],
310            parent: Some("bd-parent".to_string()),
311        };
312
313        let json = serde_json::to_string(&details).unwrap();
314        assert!(json.contains("\"parent\":\"bd-parent\""));
315        assert!(json.contains("\"labels\":[\"backend\"]"));
316    }
317
318    #[test]
319    fn blocked_issue_serializes_blockers() {
320        let issue = base_issue("bd-3", "Blocked");
321        let blocked = BlockedIssue {
322            issue,
323            blocked_by_count: 2,
324            blocked_by: vec!["bd-a".to_string(), "bd-b".to_string()],
325        };
326
327        let json = serde_json::to_string(&blocked).unwrap();
328        assert!(json.contains("\"blocked_by_count\":2"));
329        assert!(json.contains("\"blocked_by\":[\"bd-a\",\"bd-b\"]"));
330    }
331}