1use 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#[derive(Debug, Clone)]
25pub struct JfmDocument {
26 pub frontmatter: JfmFrontmatter,
28
29 pub body: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type")]
38pub enum JfmFrontmatter {
39 #[serde(rename = "jira")]
41 Jira(JiraFrontmatter),
42
43 #[serde(rename = "confluence")]
45 Confluence(ConfluenceFrontmatter),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct JiraFrontmatter {
51 pub instance: String,
53
54 #[serde(default)]
56 pub key: String,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub project: Option<String>,
61
62 pub summary: String,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub status: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub issue_type: Option<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub assignee: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub priority: Option<String>,
80
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub labels: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ConfluenceFrontmatter {
89 pub instance: String,
91
92 #[serde(default)]
94 pub page_id: String,
95
96 pub title: String,
98
99 pub space_key: String,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub status: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub version: Option<u32>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub parent_id: Option<String>,
113}
114
115impl JfmFrontmatter {
116 pub fn instance(&self) -> &str {
118 match self {
119 Self::Jira(fm) => &fm.instance,
120 Self::Confluence(fm) => &fm.instance,
121 }
122 }
123
124 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 pub fn title(&self) -> &str {
134 match self {
135 Self::Jira(fm) => &fm.summary,
136 Self::Confluence(fm) => &fm.title,
137 }
138 }
139
140 pub fn doc_type(&self) -> &str {
142 match self {
143 Self::Jira(_) => "jira",
144 Self::Confluence(_) => "confluence",
145 }
146 }
147}
148
149pub 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
159pub 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
185pub 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 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 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..]; 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 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 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 #[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 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 #[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 #[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}