Skip to main content

omni_dev/cli/atlassian/
format.rs

1//! Atlassian-specific output-format helpers.
2//!
3//! The machine-readable [`OutputFormat`] enum and the generic rendering
4//! machinery (`write_output`/`output_as`, the [`JsonlSerialize`] trait, and the
5//! blanket `Vec<T>` impl) live in the shared [`crate::cli::format`] module and
6//! are re-exported here so the Atlassian command call sites are unchanged. This
7//! module adds only the [`ContentFormat`] input/output enum (JFM vs ADF) and the
8//! `JsonlSerialize` impls for Atlassian collection wrapper types.
9
10use std::io::Write;
11
12use anyhow::Result;
13use clap::ValueEnum;
14
15pub use crate::cli::format::{
16    output_as, write_items_jsonl, write_scalar_jsonl, JsonlSerialize, OutputFormat,
17};
18
19use crate::atlassian::confluence_types::{
20    ConfluenceAttachmentPage, ConfluenceSpacePage, PageSummaryPage,
21};
22use crate::atlassian::confluence_types::{
23    ConfluenceSearchResults, ConfluenceUserGetResults, ConfluenceUserSearchResults,
24};
25use crate::atlassian::jira_types::{
26    AgileBoardList, AgileSprintList, CreateMeta, JiraDevStatus, JiraDevStatusSummary,
27    JiraProjectList, JiraProjectVersionList, JiraSearchResult, JiraUserGetResults,
28    JiraUserSearchResults, JiraWatcherList, JiraWorklogList,
29};
30
31/// Output/input format for Atlassian content (read/write/create commands).
32#[derive(Clone, Debug, Default, ValueEnum)]
33pub enum ContentFormat {
34    /// JFM markdown with YAML frontmatter.
35    #[default]
36    Jfm,
37    /// Raw Atlassian Document Format JSON.
38    Adf,
39}
40
41impl JsonlSerialize for AgileBoardList {
42    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
43        write_items_jsonl(self.boards.iter(), out)
44    }
45}
46
47impl JsonlSerialize for AgileSprintList {
48    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
49        write_items_jsonl(self.sprints.iter(), out)
50    }
51}
52
53impl JsonlSerialize for JiraSearchResult {
54    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
55        write_items_jsonl(self.issues.iter(), out)
56    }
57}
58
59impl JsonlSerialize for JiraProjectList {
60    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
61        write_items_jsonl(self.projects.iter(), out)
62    }
63}
64
65impl JsonlSerialize for CreateMeta {
66    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
67        write_items_jsonl(self.fields.iter(), out)
68    }
69}
70
71impl JsonlSerialize for JiraProjectVersionList {
72    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
73        write_items_jsonl(self.versions.iter(), out)
74    }
75}
76
77impl JsonlSerialize for JiraWatcherList {
78    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
79        write_items_jsonl(self.watchers.iter(), out)
80    }
81}
82
83impl JsonlSerialize for JiraWorklogList {
84    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
85        write_items_jsonl(self.worklogs.iter(), out)
86    }
87}
88
89impl JsonlSerialize for ConfluenceSearchResults {
90    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
91        write_items_jsonl(self.results.iter(), out)
92    }
93}
94
95impl JsonlSerialize for ConfluenceUserSearchResults {
96    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
97        write_items_jsonl(self.users.iter(), out)
98    }
99}
100
101impl JsonlSerialize for ConfluenceAttachmentPage {
102    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
103        write_items_jsonl(self.results.iter(), out)
104    }
105}
106
107impl JsonlSerialize for ConfluenceSpacePage {
108    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
109        write_items_jsonl(self.results.iter(), out)
110    }
111}
112
113impl JsonlSerialize for PageSummaryPage {
114    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
115        write_items_jsonl(self.results.iter(), out)
116    }
117}
118
119impl JsonlSerialize for JiraUserSearchResults {
120    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
121        write_items_jsonl(self.users.iter(), out)
122    }
123}
124
125impl JsonlSerialize for JiraUserGetResults {
126    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
127        write_items_jsonl(self.users.iter(), out)
128    }
129}
130
131impl JsonlSerialize for ConfluenceUserGetResults {
132    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
133        write_items_jsonl(self.users.iter(), out)
134    }
135}
136
137impl JsonlSerialize for JiraDevStatus {
138    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
139        write_scalar_jsonl(self, out)
140    }
141}
142
143impl JsonlSerialize for JiraDevStatusSummary {
144    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
145        write_scalar_jsonl(self, out)
146    }
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used)]
151mod tests {
152    use super::*;
153    use crate::atlassian::confluence_types::{ConfluenceSearchResult, ConfluenceUserSearchResult};
154    use crate::atlassian::jira_types::{
155        AgileBoard, AgileSprint, JiraComment, JiraDevStatusCount, JiraIssue, JiraProject,
156        JiraProjectVersion, JiraUser, JiraWorklog,
157    };
158    use crate::cli::format::write_output;
159
160    // ── ContentFormat ──────────────────────────────────────────────
161
162    #[test]
163    fn default_is_jfm() {
164        let format = ContentFormat::default();
165        assert!(matches!(format, ContentFormat::Jfm));
166    }
167
168    #[test]
169    fn jfm_variant() {
170        let format = ContentFormat::Jfm;
171        assert!(matches!(format, ContentFormat::Jfm));
172    }
173
174    #[test]
175    fn adf_variant() {
176        let format = ContentFormat::Adf;
177        assert!(matches!(format, ContentFormat::Adf));
178    }
179
180    #[test]
181    fn debug_format() {
182        assert_eq!(format!("{:?}", ContentFormat::Jfm), "Jfm");
183        assert_eq!(format!("{:?}", ContentFormat::Adf), "Adf");
184    }
185
186    #[test]
187    fn clone() {
188        let format = ContentFormat::Adf;
189        let cloned = format;
190        assert!(matches!(cloned, ContentFormat::Adf));
191    }
192
193    // ── sample data helpers ────────────────────────────────────────
194
195    fn sample_board(id: u64, name: &str) -> AgileBoard {
196        AgileBoard {
197            id,
198            name: name.to_string(),
199            board_type: "scrum".to_string(),
200            project_key: None,
201        }
202    }
203
204    fn sample_sprint(id: u64, name: &str) -> AgileSprint {
205        AgileSprint {
206            id,
207            name: name.to_string(),
208            state: "active".to_string(),
209            goal: None,
210            start_date: None,
211            end_date: None,
212        }
213    }
214
215    fn sample_issue(key: &str) -> JiraIssue {
216        JiraIssue {
217            key: key.to_string(),
218            summary: "s".to_string(),
219            description_adf: None,
220            status: None,
221            issue_type: None,
222            assignee: None,
223            priority: None,
224            labels: vec![],
225            custom_fields: vec![],
226        }
227    }
228
229    fn sample_project(key: &str) -> JiraProject {
230        JiraProject {
231            key: key.to_string(),
232            id: "1".to_string(),
233            name: "Project".to_string(),
234            project_type: None,
235            lead: None,
236        }
237    }
238
239    fn sample_project_version(id: &str, name: &str) -> JiraProjectVersion {
240        JiraProjectVersion {
241            id: id.to_string(),
242            name: name.to_string(),
243            description: None,
244            project_key: "PROJ".to_string(),
245            released: false,
246            archived: false,
247            release_date: None,
248            start_date: None,
249        }
250    }
251
252    fn sample_user(name: &str) -> JiraUser {
253        JiraUser {
254            account_id: name.to_string(),
255            display_name: name.to_string(),
256            email_address: None,
257        }
258    }
259
260    fn sample_worklog(id: &str) -> JiraWorklog {
261        JiraWorklog {
262            id: id.to_string(),
263            author: "alice".to_string(),
264            time_spent: "1h".to_string(),
265            time_spent_seconds: 3600,
266            started: "2025-01-01T00:00:00Z".to_string(),
267            comment: None,
268        }
269    }
270
271    fn sample_confluence_result(id: &str) -> ConfluenceSearchResult {
272        ConfluenceSearchResult {
273            id: id.to_string(),
274            title: "Title".to_string(),
275            space_key: "ENG".to_string(),
276        }
277    }
278
279    fn sample_confluence_user(id: &str) -> ConfluenceUserSearchResult {
280        ConfluenceUserSearchResult {
281            account_id: Some(id.to_string()),
282            display_name: "Name".to_string(),
283            email: None,
284        }
285    }
286
287    fn sample_comment(id: &str, _body: &str) -> JiraComment {
288        JiraComment {
289            id: id.to_string(),
290            author: "alice".to_string(),
291            created: "2025-01-01T00:00:00Z".to_string(),
292            body_adf: None,
293            updated: None,
294        }
295    }
296
297    // ── wrapper type JsonlSerialize impls ──────────────────────────
298
299    fn jsonl_string<T: JsonlSerialize>(value: &T) -> String {
300        let mut buf = Vec::new();
301        value.write_jsonl(&mut buf).unwrap();
302        String::from_utf8(buf).unwrap()
303    }
304
305    #[test]
306    fn vec_of_refs_jsonl() {
307        let comment = sample_comment("c1", "hello");
308        let comments = vec![&comment];
309        let mut buf = Vec::new();
310        comments.write_jsonl(&mut buf).unwrap();
311        let out = String::from_utf8(buf).unwrap();
312        assert!(out.starts_with('{'));
313        assert!(out.ends_with('\n'));
314        assert_eq!(out.lines().count(), 1);
315    }
316
317    #[test]
318    fn agile_board_list_jsonl() {
319        let list = AgileBoardList {
320            boards: vec![sample_board(1, "B1"), sample_board(2, "B2")],
321            total: 2,
322        };
323        let out = jsonl_string(&list);
324        assert_eq!(out.lines().count(), 2);
325        assert!(out.contains("\"id\":1"));
326        assert!(out.contains("\"id\":2"));
327    }
328
329    #[test]
330    fn agile_board_list_jsonl_empty() {
331        let list = AgileBoardList {
332            boards: vec![],
333            total: 0,
334        };
335        assert_eq!(jsonl_string(&list), "");
336    }
337
338    #[test]
339    fn agile_sprint_list_jsonl() {
340        let list = AgileSprintList {
341            sprints: vec![sample_sprint(1, "Sprint 1")],
342            total: 1,
343        };
344        let out = jsonl_string(&list);
345        assert_eq!(out.lines().count(), 1);
346        assert!(out.contains("\"id\":1"));
347    }
348
349    #[test]
350    fn jira_search_result_jsonl() {
351        let result = JiraSearchResult {
352            issues: vec![sample_issue("A-1"), sample_issue("A-2")],
353            total: 2,
354        };
355        let out = jsonl_string(&result);
356        assert_eq!(out.lines().count(), 2);
357        assert!(out.contains("\"key\":\"A-1\""));
358        assert!(out.contains("\"key\":\"A-2\""));
359    }
360
361    #[test]
362    fn jira_project_list_jsonl() {
363        let list = JiraProjectList {
364            projects: vec![sample_project("P")],
365            total: 1,
366        };
367        let out = jsonl_string(&list);
368        assert_eq!(out.lines().count(), 1);
369        assert!(out.contains("\"key\":\"P\""));
370    }
371
372    #[test]
373    fn jira_project_version_list_jsonl() {
374        let list = JiraProjectVersionList {
375            versions: vec![
376                sample_project_version("10", "1.0.0"),
377                sample_project_version("11", "1.1.0"),
378            ],
379            total: 2,
380        };
381        let out = jsonl_string(&list);
382        assert_eq!(out.lines().count(), 2);
383        assert!(out.contains("\"id\":\"10\""));
384        assert!(out.contains("\"id\":\"11\""));
385    }
386
387    #[test]
388    fn jira_watcher_list_jsonl() {
389        let list = JiraWatcherList {
390            watchers: vec![sample_user("alice"), sample_user("bob")],
391            watch_count: 2,
392        };
393        let out = jsonl_string(&list);
394        assert_eq!(out.lines().count(), 2);
395    }
396
397    #[test]
398    fn jira_worklog_list_jsonl() {
399        let list = JiraWorklogList {
400            worklogs: vec![sample_worklog("w1")],
401            total: 1,
402        };
403        let out = jsonl_string(&list);
404        assert_eq!(out.lines().count(), 1);
405        assert!(out.contains("\"id\":\"w1\""));
406    }
407
408    #[test]
409    fn confluence_search_results_jsonl() {
410        let list = ConfluenceSearchResults {
411            results: vec![sample_confluence_result("r1")],
412            total: 1,
413        };
414        let out = jsonl_string(&list);
415        assert_eq!(out.lines().count(), 1);
416        assert!(out.contains("\"id\":\"r1\""));
417    }
418
419    #[test]
420    fn confluence_user_search_results_jsonl() {
421        let list = ConfluenceUserSearchResults {
422            users: vec![sample_confluence_user("u1")],
423            total: 1,
424        };
425        let out = jsonl_string(&list);
426        assert_eq!(out.lines().count(), 1);
427        assert!(out.contains("\"accountId\":\"u1\"") || out.contains("\"account_id\":\"u1\""));
428    }
429
430    #[test]
431    fn confluence_attachment_page_jsonl() {
432        use crate::atlassian::confluence_types::{ConfluenceAttachment, ConfluenceAttachmentPage};
433        let page = ConfluenceAttachmentPage {
434            results: vec![
435                ConfluenceAttachment {
436                    id: "a1".to_string(),
437                    title: "one.png".to_string(),
438                    media_type: Some("image/png".to_string()),
439                    file_size: Some(100),
440                    download_url: None,
441                    version: None,
442                    page_id: None,
443                    file_id: None,
444                },
445                ConfluenceAttachment {
446                    id: "a2".to_string(),
447                    title: "two.pdf".to_string(),
448                    media_type: None,
449                    file_size: None,
450                    download_url: None,
451                    version: None,
452                    page_id: None,
453                    file_id: None,
454                },
455            ],
456            next_cursor: Some("NEXT".to_string()),
457        };
458        let out = jsonl_string(&page);
459        assert_eq!(out.lines().count(), 2);
460        assert!(out.contains("\"id\":\"a1\""));
461        assert!(out.contains("\"id\":\"a2\""));
462    }
463
464    #[test]
465    fn confluence_attachment_page_jsonl_empty() {
466        use crate::atlassian::confluence_types::ConfluenceAttachmentPage;
467        let page = ConfluenceAttachmentPage {
468            results: vec![],
469            next_cursor: None,
470        };
471        assert_eq!(jsonl_string(&page), "");
472    }
473
474    #[test]
475    fn confluence_space_page_jsonl() {
476        use crate::atlassian::confluence_types::{ConfluenceSpace, ConfluenceSpacePage};
477        let page = ConfluenceSpacePage {
478            results: vec![
479                ConfluenceSpace {
480                    id: "100".to_string(),
481                    key: "ENG".to_string(),
482                    name: "Engineering".to_string(),
483                    type_: "global".to_string(),
484                    status: "current".to_string(),
485                    homepage_id: Some("200".to_string()),
486                },
487                ConfluenceSpace {
488                    id: "101".to_string(),
489                    key: "OPS".to_string(),
490                    name: "Operations".to_string(),
491                    type_: "global".to_string(),
492                    status: "archived".to_string(),
493                    homepage_id: None,
494                },
495            ],
496            next_cursor: Some("NEXT".to_string()),
497        };
498        let out = jsonl_string(&page);
499        assert_eq!(out.lines().count(), 2);
500        assert!(out.contains("\"id\":\"100\""));
501        assert!(out.contains("\"key\":\"ENG\""));
502        assert!(out.contains("\"homepageId\":\"200\""));
503    }
504
505    #[test]
506    fn confluence_space_page_jsonl_empty() {
507        use crate::atlassian::confluence_types::ConfluenceSpacePage;
508        let page = ConfluenceSpacePage {
509            results: vec![],
510            next_cursor: None,
511        };
512        assert_eq!(jsonl_string(&page), "");
513    }
514
515    #[test]
516    fn jira_user_search_results_jsonl() {
517        use crate::atlassian::jira_types::{JiraUserSearchResult, JiraUserSearchResults};
518        let list = JiraUserSearchResults {
519            users: vec![
520                JiraUserSearchResult {
521                    account_id: "u1".to_string(),
522                    display_name: Some("Alice".to_string()),
523                    email_address: Some("alice@example.com".to_string()),
524                    active: true,
525                    account_type: Some("atlassian".to_string()),
526                },
527                JiraUserSearchResult {
528                    account_id: "u2".to_string(),
529                    display_name: None,
530                    email_address: None,
531                    active: false,
532                    account_type: None,
533                },
534            ],
535            count: 2,
536        };
537        let out = jsonl_string(&list);
538        assert_eq!(out.lines().count(), 2);
539        assert!(out.contains("\"account_id\":\"u1\""));
540        assert!(out.contains("\"account_id\":\"u2\""));
541    }
542
543    #[test]
544    fn jira_user_get_results_jsonl() {
545        use crate::atlassian::jira_types::{JiraUserGetResults, JiraUserRecord};
546        let list = JiraUserGetResults {
547            users: vec![
548                JiraUserRecord {
549                    account_id: "u1".to_string(),
550                    display_name: Some("Alice".to_string()),
551                    email_address: Some("alice@example.com".to_string()),
552                    active: Some(true),
553                    account_type: Some("atlassian".to_string()),
554                    error: None,
555                },
556                JiraUserRecord {
557                    account_id: "bad".to_string(),
558                    display_name: None,
559                    email_address: None,
560                    active: None,
561                    account_type: None,
562                    error: Some("HTTP 404".to_string()),
563                },
564            ],
565        };
566        let out = jsonl_string(&list);
567        assert_eq!(out.lines().count(), 2);
568        assert!(out.contains("\"account_id\":\"u1\""));
569        assert!(out.contains("\"error\":\"HTTP 404\""));
570    }
571
572    #[test]
573    fn confluence_user_get_results_jsonl() {
574        use crate::atlassian::confluence_types::{ConfluenceUserGetResults, ConfluenceUserRecord};
575        let list = ConfluenceUserGetResults {
576            users: vec![ConfluenceUserRecord {
577                account_id: "abc".to_string(),
578                display_name: Some("Alice".to_string()),
579                email: Some("a@x.com".to_string()),
580                account_type: Some("atlassian".to_string()),
581                active: None,
582                error: None,
583            }],
584        };
585        let out = jsonl_string(&list);
586        assert_eq!(out.lines().count(), 1);
587        assert!(out.contains("\"account_id\":\"abc\""));
588    }
589
590    #[test]
591    fn jira_dev_status_jsonl_single_line() {
592        let status = JiraDevStatus {
593            pull_requests: vec![],
594            branches: vec![],
595            repositories: vec![],
596        };
597        let out = jsonl_string(&status);
598        assert_eq!(out.lines().count(), 1);
599    }
600
601    #[test]
602    fn jira_dev_status_summary_jsonl_single_line() {
603        let summary = JiraDevStatusSummary {
604            pullrequest: JiraDevStatusCount {
605                count: 0,
606                providers: vec![],
607            },
608            branch: JiraDevStatusCount {
609                count: 0,
610                providers: vec![],
611            },
612            repository: JiraDevStatusCount {
613                count: 0,
614                providers: vec![],
615            },
616        };
617        let out = jsonl_string(&summary);
618        assert_eq!(out.lines().count(), 1);
619    }
620
621    // ── output_as / write_output over wrapper types ────────────────
622
623    #[test]
624    fn output_as_jsonl_wrapper_returns_true() {
625        let list = AgileBoardList {
626            boards: vec![sample_board(1, "b")],
627            total: 1,
628        };
629        assert!(output_as(&list, &OutputFormat::Jsonl).unwrap());
630    }
631
632    #[test]
633    fn write_output_jsonl_wrapper() {
634        let list = AgileBoardList {
635            boards: vec![sample_board(1, "b1"), sample_board(2, "b2")],
636            total: 2,
637        };
638        let mut buf = Vec::new();
639        let wrote = write_output(&list, &OutputFormat::Jsonl, &mut buf).unwrap();
640        assert!(wrote);
641        let out = String::from_utf8(buf).unwrap();
642        assert_eq!(out.lines().count(), 2);
643        assert!(out.contains("\"id\":1"));
644        assert!(out.contains("\"id\":2"));
645    }
646
647    #[test]
648    fn write_output_json_wrapper_includes_total_field() {
649        let list = AgileBoardList {
650            boards: vec![sample_board(1, "b1")],
651            total: 42,
652        };
653        let mut buf = Vec::new();
654        write_output(&list, &OutputFormat::Json, &mut buf).unwrap();
655        let out = String::from_utf8(buf).unwrap();
656        assert!(out.contains("\"total\": 42"));
657    }
658
659    #[test]
660    fn write_output_yaml_wrapper_includes_total_field() {
661        let list = AgileBoardList {
662            boards: vec![],
663            total: 0,
664        };
665        let mut buf = Vec::new();
666        write_output(&list, &OutputFormat::Yaml, &mut buf).unwrap();
667        let out = String::from_utf8(buf).unwrap();
668        assert!(out.contains("total: 0"));
669    }
670}