Skip to main content

omni_dev/atlassian/
document.rs

1//! JFM document format: YAML frontmatter + markdown body.
2//!
3//! Parses and renders documents in the format:
4//! ```text
5//! ---
6//! type: jira
7//! key: PROJ-123
8//! summary: Issue title
9//! ---
10//!
11//! Markdown body content here.
12//! ```
13
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16
17use crate::atlassian::adf::AdfDocument;
18use crate::atlassian::api::{ContentItem, ContentMetadata};
19use crate::atlassian::client::JiraIssue;
20use crate::atlassian::convert::adf_to_markdown;
21use crate::atlassian::error::AtlassianError;
22
23/// A JFM document consisting of YAML frontmatter and a markdown body.
24#[derive(Debug, Clone)]
25pub struct JfmDocument {
26    /// Parsed frontmatter metadata fields.
27    pub frontmatter: JfmFrontmatter,
28
29    /// Raw markdown body (not parsed — passed through to/from ADF conversion).
30    pub body: String,
31}
32
33/// YAML frontmatter for a JFM document.
34///
35/// Dispatched by the `type` field to the appropriate backend-specific struct.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type")]
38pub enum JfmFrontmatter {
39    /// JIRA issue frontmatter.
40    #[serde(rename = "jira")]
41    Jira(JiraFrontmatter),
42
43    /// Confluence page frontmatter.
44    #[serde(rename = "confluence")]
45    Confluence(ConfluenceFrontmatter),
46}
47
48/// JIRA-specific frontmatter fields.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct JiraFrontmatter {
51    /// Atlassian instance base URL.
52    pub instance: String,
53
54    /// JIRA issue key (e.g., "PROJ-123"). Empty when creating a new issue.
55    #[serde(default)]
56    pub key: String,
57
58    /// Project key (e.g., "PROJ"). Used when creating issues without an existing key.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub project: Option<String>,
61
62    /// Issue summary (title).
63    pub summary: String,
64
65    /// Issue status name.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub status: Option<String>,
68
69    /// Issue type name (Bug, Story, Task, etc.).
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub issue_type: Option<String>,
72
73    /// Assignee display name.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub assignee: Option<String>,
76
77    /// Priority name.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub priority: Option<String>,
80
81    /// Labels applied to the issue.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub labels: Vec<String>,
84}
85
86/// Confluence-specific frontmatter fields.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ConfluenceFrontmatter {
89    /// Atlassian instance base URL.
90    pub instance: String,
91
92    /// Confluence page ID. Empty when creating a new page.
93    #[serde(default)]
94    pub page_id: String,
95
96    /// Page title.
97    pub title: String,
98
99    /// Space key (e.g., "ENG").
100    pub space_key: String,
101
102    /// Page status ("current" or "draft").
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub status: Option<String>,
105
106    /// Page version number.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub version: Option<u32>,
109
110    /// Parent page ID.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub parent_id: Option<String>,
113}
114
115impl JfmFrontmatter {
116    /// Returns the Atlassian instance URL.
117    pub fn instance(&self) -> &str {
118        match self {
119            Self::Jira(fm) => &fm.instance,
120            Self::Confluence(fm) => &fm.instance,
121        }
122    }
123
124    /// Returns the content identifier (JIRA key or Confluence page ID).
125    pub fn id(&self) -> &str {
126        match self {
127            Self::Jira(fm) => &fm.key,
128            Self::Confluence(fm) => &fm.page_id,
129        }
130    }
131
132    /// Returns the content title (JIRA summary or Confluence page title).
133    pub fn title(&self) -> &str {
134        match self {
135            Self::Jira(fm) => &fm.summary,
136            Self::Confluence(fm) => &fm.title,
137        }
138    }
139
140    /// Returns the document type name.
141    pub fn doc_type(&self) -> &str {
142        match self {
143            Self::Jira(_) => "jira",
144            Self::Confluence(_) => "confluence",
145        }
146    }
147}
148
149/// Validates that a string looks like a JIRA issue key (e.g., "PROJ-123").
150pub fn validate_issue_key(key: &str) -> Result<()> {
151    let re =
152        regex::Regex::new(r"^[A-Z][A-Z0-9]+-\d+$").context("Failed to compile issue key regex")?;
153    if !re.is_match(key) {
154        anyhow::bail!("Invalid JIRA issue key: '{key}'. Expected format: PROJ-123");
155    }
156    Ok(())
157}
158
159/// Converts a [`JiraIssue`] into a [`JfmDocument`] with YAML frontmatter.
160pub fn issue_to_jfm_document(issue: &JiraIssue, instance_url: &str) -> Result<JfmDocument> {
161    let body = if let Some(ref adf_value) = issue.description_adf {
162        let adf_doc: AdfDocument =
163            serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
164        adf_to_markdown(&adf_doc)?
165    } else {
166        String::new()
167    };
168
169    Ok(JfmDocument {
170        frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
171            instance: instance_url.to_string(),
172            key: issue.key.clone(),
173            project: None,
174            summary: issue.summary.clone(),
175            status: issue.status.clone(),
176            issue_type: issue.issue_type.clone(),
177            assignee: issue.assignee.clone(),
178            priority: issue.priority.clone(),
179            labels: issue.labels.clone(),
180        }),
181        body,
182    })
183}
184
185/// Converts a [`ContentItem`] into a [`JfmDocument`] with YAML frontmatter.
186///
187/// Dispatches on the [`ContentMetadata`] variant to populate the correct
188/// frontmatter fields for JIRA or Confluence content.
189pub fn content_item_to_document(item: &ContentItem, instance_url: &str) -> Result<JfmDocument> {
190    let body = if let Some(ref adf_value) = item.body_adf {
191        let adf_doc: AdfDocument =
192            serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
193        adf_to_markdown(&adf_doc)?
194    } else {
195        String::new()
196    };
197
198    let frontmatter = match &item.metadata {
199        ContentMetadata::Jira {
200            status,
201            issue_type,
202            assignee,
203            priority,
204            labels,
205        } => JfmFrontmatter::Jira(JiraFrontmatter {
206            instance: instance_url.to_string(),
207            key: item.id.clone(),
208            project: None,
209            summary: item.title.clone(),
210            status: status.clone(),
211            issue_type: issue_type.clone(),
212            assignee: assignee.clone(),
213            priority: priority.clone(),
214            labels: labels.clone(),
215        }),
216        ContentMetadata::Confluence {
217            space_key,
218            status,
219            version,
220            parent_id,
221        } => JfmFrontmatter::Confluence(ConfluenceFrontmatter {
222            instance: instance_url.to_string(),
223            page_id: item.id.clone(),
224            title: item.title.clone(),
225            space_key: space_key.clone(),
226            status: status.clone(),
227            version: *version,
228            parent_id: parent_id.clone(),
229        }),
230    };
231
232    Ok(JfmDocument { frontmatter, body })
233}
234
235impl JfmDocument {
236    /// Parses a JFM document from a string.
237    ///
238    /// Expects the format: `---\n<yaml frontmatter>\n---\n<markdown body>`
239    pub fn parse(input: &str) -> Result<Self> {
240        let trimmed = input.trim_start();
241
242        if !trimmed.starts_with("---") {
243            return Err(AtlassianError::InvalidDocument(
244                "Document must start with '---' frontmatter delimiter".to_string(),
245            )
246            .into());
247        }
248
249        // Find the closing '---' delimiter (skip the opening one)
250        let after_opening = &trimmed[3..];
251        let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening);
252
253        let closing_pos = after_opening.find("\n---").ok_or_else(|| {
254            AtlassianError::InvalidDocument(
255                "Missing closing '---' frontmatter delimiter".to_string(),
256            )
257        })?;
258
259        let frontmatter_yaml = &after_opening[..closing_pos];
260        let after_closing = &after_opening[closing_pos + 4..]; // skip "\n---"
261
262        // Strip the first newline after the closing delimiter
263        let body = after_closing
264            .strip_prefix('\n')
265            .unwrap_or(after_closing)
266            .to_string();
267
268        let frontmatter: JfmFrontmatter = serde_yaml::from_str(frontmatter_yaml)
269            .context("Failed to parse JFM frontmatter YAML")?;
270
271        Ok(Self { frontmatter, body })
272    }
273
274    /// Renders the document back to a string with YAML frontmatter and markdown body.
275    pub fn render(&self) -> Result<String> {
276        let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)
277            .context("Failed to serialize JFM frontmatter to YAML")?;
278
279        let mut output = String::new();
280        output.push_str("---\n");
281        output.push_str(&frontmatter_yaml);
282        output.push_str("---\n");
283        if !self.body.is_empty() {
284            output.push('\n');
285            output.push_str(&self.body);
286            // Ensure trailing newline
287            if !self.body.ends_with('\n') {
288                output.push('\n');
289            }
290        }
291
292        Ok(output)
293    }
294}
295
296#[cfg(test)]
297#[allow(clippy::unwrap_used, clippy::expect_used)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn parse_basic_document() {
303        let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Fix the bug\n---\n\nThis is the description.\n";
304        let doc = JfmDocument::parse(input).unwrap();
305        assert_eq!(doc.frontmatter.doc_type(), "jira");
306        assert_eq!(doc.frontmatter.id(), "PROJ-123");
307        assert_eq!(doc.frontmatter.title(), "Fix the bug");
308        assert_eq!(doc.body, "\nThis is the description.\n");
309    }
310
311    #[test]
312    fn parse_with_optional_fields() {
313        let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-456\nsummary: A story\nstatus: In Progress\nissue_type: Story\nassignee: Alice\npriority: High\nlabels:\n  - backend\n  - auth\n---\n\nDescription here.\n";
314        let doc = JfmDocument::parse(input).unwrap();
315        match &doc.frontmatter {
316            JfmFrontmatter::Jira(fm) => {
317                assert_eq!(fm.status.as_deref(), Some("In Progress"));
318                assert_eq!(fm.issue_type.as_deref(), Some("Story"));
319                assert_eq!(fm.assignee.as_deref(), Some("Alice"));
320                assert_eq!(fm.priority.as_deref(), Some("High"));
321                assert_eq!(fm.labels, vec!["backend", "auth"]);
322            }
323            _ => panic!("Expected Jira frontmatter"),
324        }
325    }
326
327    #[test]
328    fn parse_empty_body() {
329        let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Empty\n---\n";
330        let doc = JfmDocument::parse(input).unwrap();
331        assert_eq!(doc.body, "");
332    }
333
334    #[test]
335    fn parse_body_with_triple_dashes() {
336        let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Dashes\n---\n\nContent with --- dashes in it.\n";
337        let doc = JfmDocument::parse(input).unwrap();
338        assert!(doc.body.contains("--- dashes"));
339    }
340
341    #[test]
342    fn parse_missing_opening_delimiter() {
343        let input = "type: jira\nkey: PROJ-1\n";
344        let result = JfmDocument::parse(input);
345        assert!(result.is_err());
346    }
347
348    #[test]
349    fn parse_missing_closing_delimiter() {
350        let input = "---\ntype: jira\nkey: PROJ-1\n";
351        let result = JfmDocument::parse(input);
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn render_basic_document() {
357        let doc = JfmDocument {
358            frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
359                instance: "https://org.atlassian.net".to_string(),
360                key: "PROJ-123".to_string(),
361                project: None,
362                summary: "Fix the bug".to_string(),
363                status: None,
364                issue_type: None,
365                assignee: None,
366                priority: None,
367                labels: vec![],
368            }),
369            body: "Description here.".to_string(),
370        };
371
372        let output = doc.render().unwrap();
373        assert!(output.starts_with("---\n"));
374        assert!(output.contains("key: PROJ-123"));
375        assert!(output.contains("summary: Fix the bug"));
376        assert!(output.contains("---\n\nDescription here.\n"));
377    }
378
379    #[test]
380    fn render_round_trip() {
381        let doc = JfmDocument {
382            frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
383                instance: "https://org.atlassian.net".to_string(),
384                key: "PROJ-789".to_string(),
385                project: None,
386                summary: "Round trip test".to_string(),
387                status: Some("Open".to_string()),
388                issue_type: Some("Bug".to_string()),
389                assignee: None,
390                priority: None,
391                labels: vec!["test".to_string()],
392            }),
393            body: "# Heading\n\nSome text.\n".to_string(),
394        };
395
396        let rendered = doc.render().unwrap();
397        let restored = JfmDocument::parse(&rendered).unwrap();
398
399        assert_eq!(doc.frontmatter.id(), restored.frontmatter.id());
400        assert_eq!(doc.frontmatter.title(), restored.frontmatter.title());
401        match &restored.frontmatter {
402            JfmFrontmatter::Jira(fm) => {
403                assert_eq!(fm.status.as_deref(), Some("Open"));
404            }
405            _ => panic!("Expected Jira frontmatter"),
406        }
407        assert!(restored.body.contains("# Heading"));
408        assert!(restored.body.contains("Some text."));
409    }
410
411    // ── validate_issue_key tests ────────��────────────────────────────
412
413    #[test]
414    fn valid_issue_keys() {
415        assert!(validate_issue_key("PROJ-123").is_ok());
416        assert!(validate_issue_key("AB-1").is_ok());
417        assert!(validate_issue_key("A1B-999").is_ok());
418    }
419
420    #[test]
421    fn invalid_issue_keys() {
422        assert!(validate_issue_key("proj-123").is_err());
423        assert!(validate_issue_key("PROJ").is_err());
424        assert!(validate_issue_key("PROJ-").is_err());
425        assert!(validate_issue_key("-123").is_err());
426        assert!(validate_issue_key("").is_err());
427    }
428
429    // ── issue_to_jfm_document tests ─────────��─────────────────────────
430
431    fn sample_issue() -> JiraIssue {
432        JiraIssue {
433            key: "TEST-42".to_string(),
434            summary: "Fix the widget".to_string(),
435            description_adf: Some(serde_json::json!({
436                "version": 1,
437                "type": "doc",
438                "content": [{
439                    "type": "paragraph",
440                    "content": [{"type": "text", "text": "Hello world"}]
441                }]
442            })),
443            status: Some("Open".to_string()),
444            issue_type: Some("Bug".to_string()),
445            assignee: Some("Alice".to_string()),
446            priority: Some("High".to_string()),
447            labels: vec!["backend".to_string()],
448        }
449    }
450
451    #[test]
452    fn issue_to_jfm_with_description() {
453        let issue = sample_issue();
454        let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
455        assert_eq!(doc.frontmatter.id(), "TEST-42");
456        assert_eq!(doc.frontmatter.title(), "Fix the widget");
457        match &doc.frontmatter {
458            JfmFrontmatter::Jira(fm) => {
459                assert_eq!(fm.status.as_deref(), Some("Open"));
460                assert_eq!(fm.issue_type.as_deref(), Some("Bug"));
461            }
462            _ => panic!("Expected Jira frontmatter"),
463        }
464        assert!(doc.body.contains("Hello world"));
465    }
466
467    #[test]
468    fn issue_to_jfm_without_description() {
469        let mut issue = sample_issue();
470        issue.description_adf = None;
471        let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
472        assert_eq!(doc.body, "");
473    }
474
475    #[test]
476    fn issue_to_jfm_minimal_fields() {
477        let issue = JiraIssue {
478            key: "MIN-1".to_string(),
479            summary: "Minimal".to_string(),
480            description_adf: None,
481            status: None,
482            issue_type: None,
483            assignee: None,
484            priority: None,
485            labels: vec![],
486        };
487        let doc = issue_to_jfm_document(&issue, "https://test.atlassian.net").unwrap();
488        assert_eq!(doc.frontmatter.instance(), "https://test.atlassian.net");
489        match &doc.frontmatter {
490            JfmFrontmatter::Jira(fm) => {
491                assert!(fm.status.is_none());
492                assert!(fm.labels.is_empty());
493            }
494            _ => panic!("Expected Jira frontmatter"),
495        }
496    }
497
498    #[test]
499    fn issue_to_jfm_renders_correctly() {
500        let issue = sample_issue();
501        let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
502        let rendered = doc.render().unwrap();
503        assert!(rendered.starts_with("---\n"));
504        assert!(rendered.contains("key: TEST-42"));
505        assert!(rendered.contains("Hello world"));
506    }
507
508    #[test]
509    fn render_skips_none_and_empty_fields() {
510        let doc = JfmDocument {
511            frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
512                instance: "https://org.atlassian.net".to_string(),
513                key: "PROJ-1".to_string(),
514                project: None,
515                summary: "Minimal".to_string(),
516                status: None,
517                issue_type: None,
518                assignee: None,
519                priority: None,
520                labels: vec![],
521            }),
522            body: String::new(),
523        };
524
525        let output = doc.render().unwrap();
526        assert!(!output.contains("status:"));
527        assert!(!output.contains("issue_type:"));
528        assert!(!output.contains("labels:"));
529    }
530
531    // ── Confluence frontmatter tests ───���─────────────────────────────
532
533    #[test]
534    fn parse_confluence_document() {
535        let input = "---\ntype: confluence\ninstance: https://org.atlassian.net\npage_id: '12345'\ntitle: Architecture Overview\nspace_key: ENG\nstatus: current\nversion: 7\n---\n\nPage body here.\n";
536        let doc = JfmDocument::parse(input).unwrap();
537        assert_eq!(doc.frontmatter.doc_type(), "confluence");
538        assert_eq!(doc.frontmatter.id(), "12345");
539        assert_eq!(doc.frontmatter.title(), "Architecture Overview");
540        match &doc.frontmatter {
541            JfmFrontmatter::Confluence(fm) => {
542                assert_eq!(fm.space_key, "ENG");
543                assert_eq!(fm.status.as_deref(), Some("current"));
544                assert_eq!(fm.version, Some(7));
545            }
546            _ => panic!("Expected Confluence frontmatter"),
547        }
548    }
549
550    #[test]
551    fn render_confluence_document() {
552        let doc = JfmDocument {
553            frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
554                instance: "https://org.atlassian.net".to_string(),
555                page_id: "12345".to_string(),
556                title: "Architecture Overview".to_string(),
557                space_key: "ENG".to_string(),
558                status: Some("current".to_string()),
559                version: Some(7),
560                parent_id: None,
561            }),
562            body: "Page body here.\n".to_string(),
563        };
564
565        let output = doc.render().unwrap();
566        assert!(output.starts_with("---\n"));
567        assert!(output.contains("type: confluence"));
568        assert!(output.contains("page_id:"));
569        assert!(output.contains("space_key: ENG"));
570        assert!(output.contains("Page body here."));
571    }
572
573    #[test]
574    fn confluence_round_trip() {
575        let doc = JfmDocument {
576            frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
577                instance: "https://org.atlassian.net".to_string(),
578                page_id: "99999".to_string(),
579                title: "Round trip".to_string(),
580                space_key: "DEV".to_string(),
581                status: None,
582                version: Some(3),
583                parent_id: Some("88888".to_string()),
584            }),
585            body: "Content.\n".to_string(),
586        };
587
588        let rendered = doc.render().unwrap();
589        let restored = JfmDocument::parse(&rendered).unwrap();
590        assert_eq!(restored.frontmatter.id(), "99999");
591        assert_eq!(restored.frontmatter.title(), "Round trip");
592        match &restored.frontmatter {
593            JfmFrontmatter::Confluence(fm) => {
594                assert_eq!(fm.space_key, "DEV");
595                assert_eq!(fm.version, Some(3));
596                assert_eq!(fm.parent_id.as_deref(), Some("88888"));
597            }
598            _ => panic!("Expected Confluence frontmatter"),
599        }
600    }
601
602    // ── content_item_to_document tests ───────────────────────────────
603
604    #[test]
605    fn content_item_jira_to_document() {
606        let item = ContentItem {
607            id: "PROJ-42".to_string(),
608            title: "A JIRA issue".to_string(),
609            body_adf: Some(serde_json::json!({
610                "version": 1,
611                "type": "doc",
612                "content": [{
613                    "type": "paragraph",
614                    "content": [{"type": "text", "text": "Content"}]
615                }]
616            })),
617            metadata: ContentMetadata::Jira {
618                status: Some("Open".to_string()),
619                issue_type: Some("Bug".to_string()),
620                assignee: None,
621                priority: None,
622                labels: vec![],
623            },
624        };
625        let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
626        assert_eq!(doc.frontmatter.doc_type(), "jira");
627        assert_eq!(doc.frontmatter.id(), "PROJ-42");
628        assert!(doc.body.contains("Content"));
629    }
630
631    #[test]
632    fn content_item_confluence_to_document() {
633        let item = ContentItem {
634            id: "12345".to_string(),
635            title: "A Confluence page".to_string(),
636            body_adf: None,
637            metadata: ContentMetadata::Confluence {
638                space_key: "ENG".to_string(),
639                status: Some("current".to_string()),
640                version: Some(5),
641                parent_id: None,
642            },
643        };
644        let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
645        assert_eq!(doc.frontmatter.doc_type(), "confluence");
646        assert_eq!(doc.frontmatter.id(), "12345");
647        assert_eq!(doc.frontmatter.title(), "A Confluence page");
648    }
649}