1use std::collections::BTreeMap;
15use std::fmt::Write as _;
16
17use anyhow::{Context, Result};
18use serde::{Deserialize, Serialize};
19
20use crate::atlassian::adf::AdfDocument;
21use crate::atlassian::api::{ContentItem, ContentMetadata};
22use crate::atlassian::client::{JiraCustomField, JiraIssue};
23use crate::atlassian::convert::adf_to_markdown;
24use crate::atlassian::error::AtlassianError;
25
26#[derive(Debug, Clone)]
28pub struct JfmDocument {
29 pub frontmatter: JfmFrontmatter,
31
32 pub body: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type")]
41pub enum JfmFrontmatter {
42 #[serde(rename = "jira")]
44 Jira(JiraFrontmatter),
45
46 #[serde(rename = "confluence")]
48 Confluence(ConfluenceFrontmatter),
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct JiraFrontmatter {
54 pub instance: String,
56
57 #[serde(default)]
59 pub key: String,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub project: Option<String>,
64
65 pub summary: String,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub status: Option<String>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub issue_type: Option<String>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub assignee: Option<String>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub priority: Option<String>,
83
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub labels: Vec<String>,
87
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94 pub custom_fields: BTreeMap<String, serde_yaml::Value>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ConfluenceFrontmatter {
100 pub instance: String,
102
103 #[serde(default)]
105 pub page_id: String,
106
107 pub title: String,
109
110 pub space_key: String,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub status: Option<String>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub version: Option<u32>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub parent_id: Option<String>,
124}
125
126impl JfmFrontmatter {
127 pub fn instance(&self) -> &str {
129 match self {
130 Self::Jira(fm) => &fm.instance,
131 Self::Confluence(fm) => &fm.instance,
132 }
133 }
134
135 pub fn id(&self) -> &str {
137 match self {
138 Self::Jira(fm) => &fm.key,
139 Self::Confluence(fm) => &fm.page_id,
140 }
141 }
142
143 pub fn title(&self) -> &str {
145 match self {
146 Self::Jira(fm) => &fm.summary,
147 Self::Confluence(fm) => &fm.title,
148 }
149 }
150
151 pub fn doc_type(&self) -> &str {
153 match self {
154 Self::Jira(_) => "jira",
155 Self::Confluence(_) => "confluence",
156 }
157 }
158
159 pub fn jira_custom_fields(&self) -> Option<&BTreeMap<String, serde_yaml::Value>> {
162 match self {
163 Self::Jira(fm) => Some(&fm.custom_fields),
164 Self::Confluence(_) => None,
165 }
166 }
167}
168
169pub fn validate_issue_key(key: &str) -> Result<()> {
171 let re =
172 regex::Regex::new(r"^[A-Z][A-Z0-9]+-\d+$").context("Failed to compile issue key regex")?;
173 if !re.is_match(key) {
174 anyhow::bail!("Invalid JIRA issue key: '{key}'. Expected format: PROJ-123");
175 }
176 Ok(())
177}
178
179pub fn issue_to_jfm_document(issue: &JiraIssue, instance_url: &str) -> Result<JfmDocument> {
187 let mut body = if let Some(ref adf_value) = issue.description_adf {
188 let adf_doc: AdfDocument =
189 serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
190 adf_to_markdown(&adf_doc)?
191 } else {
192 String::new()
193 };
194
195 let mut custom_scalars: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
196 for field in &issue.custom_fields {
197 render_custom_field(field, &mut body, &mut custom_scalars)?;
198 }
199
200 Ok(JfmDocument {
201 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
202 instance: instance_url.to_string(),
203 key: issue.key.clone(),
204 project: None,
205 summary: issue.summary.clone(),
206 status: issue.status.clone(),
207 issue_type: issue.issue_type.clone(),
208 assignee: issue.assignee.clone(),
209 priority: issue.priority.clone(),
210 labels: issue.labels.clone(),
211 custom_fields: custom_scalars,
212 }),
213 body,
214 })
215}
216
217fn render_custom_field(
220 field: &JiraCustomField,
221 body: &mut String,
222 scalars: &mut BTreeMap<String, serde_yaml::Value>,
223) -> Result<()> {
224 if is_adf_document(&field.value) {
225 let adf_doc: AdfDocument = serde_json::from_value(field.value.clone())
226 .with_context(|| format!("Failed to parse ADF value for {}", field.id))?;
227 let section_md = adf_to_markdown(&adf_doc)?;
228 append_custom_section(body, field, §ion_md);
229 } else if let Some(scalar) = extract_custom_field_scalar(&field.value) {
230 scalars.insert(field.name.clone(), scalar);
231 }
232 Ok(())
234}
235
236fn append_custom_section(body: &mut String, field: &JiraCustomField, section_md: &str) {
238 if !body.is_empty() && !body.ends_with('\n') {
239 body.push('\n');
240 }
241 if !body.is_empty() {
242 body.push('\n');
243 }
244 let _ = write!(
245 body,
246 "---\n<!-- field: {} ({}) -->\n\n{}",
247 field.name, field.id, section_md
248 );
249 if !body.ends_with('\n') {
250 body.push('\n');
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct CustomFieldSection {
261 pub name: String,
263
264 pub id: String,
266
267 pub body: String,
269}
270
271pub(crate) fn split_custom_sections(body: &str) -> (String, Vec<CustomFieldSection>) {
280 let mut markers: Vec<(usize, usize, String, String)> = Vec::new();
282 let mut cursor = 0;
283
284 while cursor < body.len() {
285 let Some(marker_start) = find_next_marker(body, cursor) else {
286 break;
287 };
288
289 let after_dashes = marker_start + 3;
291 let after_nl = if body[after_dashes..].starts_with("\r\n") {
292 after_dashes + 2
293 } else if body[after_dashes..].starts_with('\n') {
294 after_dashes + 1
295 } else {
296 cursor = after_dashes;
297 continue;
298 };
299
300 if let Some((name, id, content_start)) = parse_field_tag_line(body, after_nl) {
301 markers.push((marker_start, content_start, name, id));
302 cursor = content_start;
303 } else {
304 cursor = after_nl;
305 }
306 }
307
308 if markers.is_empty() {
309 return (body.to_string(), Vec::new());
310 }
311
312 let first_marker = markers[0].0;
313 let main_body = body[..first_marker].trim_end_matches('\n').to_string();
314
315 let mut sections = Vec::with_capacity(markers.len());
316 for i in 0..markers.len() {
317 let (_marker, content_start, name, id) = &markers[i];
318 let content_end = markers.get(i + 1).map_or(body.len(), |next| next.0);
319 let raw = &body[*content_start..content_end];
320 let trimmed = raw.trim_matches('\n').to_string();
321 sections.push(CustomFieldSection {
322 name: name.clone(),
323 id: id.clone(),
324 body: trimmed,
325 });
326 }
327
328 (main_body, sections)
329}
330
331fn find_next_marker(body: &str, from: usize) -> Option<usize> {
334 if from == 0 && body.starts_with("---") {
335 return Some(0);
336 }
337 body[from..]
338 .find("\n---")
339 .map(|rel| from + rel + 1)
340 .filter(|p| *p + 3 <= body.len())
341}
342
343fn parse_field_tag_line(body: &str, start: usize) -> Option<(String, String, usize)> {
347 let rest = body.get(start..)?;
348 let line_end = rest.find('\n').unwrap_or(rest.len());
349 let line = rest[..line_end].trim_end_matches('\r');
350
351 let after_open = line.strip_prefix("<!--")?.trim_start();
352 let after_field = after_open.strip_prefix("field:")?.trim_start();
353 let close_idx = after_field.rfind("-->")?;
354 let inner = after_field[..close_idx].trim_end();
355
356 let paren_open = inner.rfind('(')?;
357 let name = inner[..paren_open].trim().to_string();
358 let rest_part = inner.get(paren_open + 1..)?;
359 let paren_close = rest_part.rfind(')')?;
360 let id = rest_part[..paren_close].trim().to_string();
361
362 if name.is_empty() || id.is_empty() {
363 return None;
364 }
365
366 let next_line_start = (start + line_end + 1).min(body.len());
367 Some((name, id, next_line_start))
368}
369
370fn is_adf_document(value: &serde_json::Value) -> bool {
373 let Some(obj) = value.as_object() else {
374 return false;
375 };
376 obj.get("type").and_then(|t| t.as_str()) == Some("doc")
377 && obj.contains_key("version")
378 && obj.contains_key("content")
379}
380
381fn extract_custom_field_scalar(value: &serde_json::Value) -> Option<serde_yaml::Value> {
392 use serde_json::Value as J;
393 match value {
394 J::Null => None,
395 J::Bool(_) | J::Number(_) | J::String(_) => json_to_yaml(value),
396 J::Array(items) => {
397 let extracted: Vec<_> = items
398 .iter()
399 .filter_map(extract_custom_field_scalar)
400 .collect();
401 if extracted.is_empty() {
402 None
403 } else {
404 Some(serde_yaml::Value::Sequence(extracted))
405 }
406 }
407 J::Object(map) => {
408 if let Some(v) = map.get("value").and_then(|v| v.as_str()) {
409 Some(serde_yaml::Value::String(v.to_string()))
410 } else if let Some(name) = map.get("displayName").and_then(|v| v.as_str()) {
411 Some(serde_yaml::Value::String(name.to_string()))
412 } else {
413 json_to_yaml(value)
414 }
415 }
416 }
417}
418
419fn json_to_yaml(value: &serde_json::Value) -> Option<serde_yaml::Value> {
420 serde_yaml::to_value(value).ok()
421}
422
423pub fn content_item_to_document(item: &ContentItem, instance_url: &str) -> Result<JfmDocument> {
428 let body = if let Some(ref adf_value) = item.body_adf {
429 let adf_doc: AdfDocument =
430 serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
431 adf_to_markdown(&adf_doc)?
432 } else {
433 String::new()
434 };
435
436 let frontmatter = match &item.metadata {
437 ContentMetadata::Jira {
438 status,
439 issue_type,
440 assignee,
441 priority,
442 labels,
443 } => JfmFrontmatter::Jira(JiraFrontmatter {
444 instance: instance_url.to_string(),
445 key: item.id.clone(),
446 project: None,
447 summary: item.title.clone(),
448 status: status.clone(),
449 issue_type: issue_type.clone(),
450 assignee: assignee.clone(),
451 priority: priority.clone(),
452 labels: labels.clone(),
453 custom_fields: BTreeMap::new(),
454 }),
455 ContentMetadata::Confluence {
456 space_key,
457 status,
458 version,
459 parent_id,
460 } => JfmFrontmatter::Confluence(ConfluenceFrontmatter {
461 instance: instance_url.to_string(),
462 page_id: item.id.clone(),
463 title: item.title.clone(),
464 space_key: space_key.clone(),
465 status: status.clone(),
466 version: *version,
467 parent_id: parent_id.clone(),
468 }),
469 };
470
471 Ok(JfmDocument { frontmatter, body })
472}
473
474impl JfmDocument {
475 pub fn parse(input: &str) -> Result<Self> {
479 let trimmed = input.trim_start();
480
481 if !trimmed.starts_with("---") {
482 return Err(AtlassianError::InvalidDocument(
483 "Document must start with '---' frontmatter delimiter".to_string(),
484 )
485 .into());
486 }
487
488 let after_opening = &trimmed[3..];
490 let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening);
491
492 let closing_pos = after_opening.find("\n---").ok_or_else(|| {
493 AtlassianError::InvalidDocument(
494 "Missing closing '---' frontmatter delimiter".to_string(),
495 )
496 })?;
497
498 let frontmatter_yaml = &after_opening[..closing_pos];
499 let after_closing = &after_opening[closing_pos + 4..]; let body = after_closing
503 .strip_prefix('\n')
504 .unwrap_or(after_closing)
505 .to_string();
506
507 let frontmatter: JfmFrontmatter = serde_yaml::from_str(frontmatter_yaml)
508 .context("Failed to parse JFM frontmatter YAML")?;
509
510 Ok(Self { frontmatter, body })
511 }
512
513 pub fn render(&self) -> Result<String> {
515 let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)
516 .context("Failed to serialize JFM frontmatter to YAML")?;
517
518 let mut output = String::new();
519 output.push_str("---\n");
520 output.push_str(&frontmatter_yaml);
521 output.push_str("---\n");
522 if !self.body.is_empty() {
523 output.push('\n');
524 output.push_str(&self.body);
525 if !self.body.ends_with('\n') {
527 output.push('\n');
528 }
529 }
530
531 Ok(output)
532 }
533
534 pub fn split_custom_sections(&self) -> (String, Vec<CustomFieldSection>) {
540 split_custom_sections(&self.body)
541 }
542}
543
544#[cfg(test)]
545#[allow(
546 clippy::unwrap_used,
547 clippy::expect_used,
548 clippy::match_wildcard_for_single_variants
549)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn parse_basic_document() {
555 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Fix the bug\n---\n\nThis is the description.\n";
556 let doc = JfmDocument::parse(input).unwrap();
557 assert_eq!(doc.frontmatter.doc_type(), "jira");
558 assert_eq!(doc.frontmatter.id(), "PROJ-123");
559 assert_eq!(doc.frontmatter.title(), "Fix the bug");
560 assert_eq!(doc.body, "\nThis is the description.\n");
561 }
562
563 #[test]
564 fn parse_with_optional_fields() {
565 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";
566 let doc = JfmDocument::parse(input).unwrap();
567 match &doc.frontmatter {
568 JfmFrontmatter::Jira(fm) => {
569 assert_eq!(fm.status.as_deref(), Some("In Progress"));
570 assert_eq!(fm.issue_type.as_deref(), Some("Story"));
571 assert_eq!(fm.assignee.as_deref(), Some("Alice"));
572 assert_eq!(fm.priority.as_deref(), Some("High"));
573 assert_eq!(fm.labels, vec!["backend", "auth"]);
574 }
575 _ => panic!("Expected Jira frontmatter"),
576 }
577 }
578
579 #[test]
580 fn parse_empty_body() {
581 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Empty\n---\n";
582 let doc = JfmDocument::parse(input).unwrap();
583 assert_eq!(doc.body, "");
584 }
585
586 #[test]
587 fn parse_body_with_triple_dashes() {
588 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Dashes\n---\n\nContent with --- dashes in it.\n";
589 let doc = JfmDocument::parse(input).unwrap();
590 assert!(doc.body.contains("--- dashes"));
591 }
592
593 #[test]
594 fn parse_missing_opening_delimiter() {
595 let input = "type: jira\nkey: PROJ-1\n";
596 let result = JfmDocument::parse(input);
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn parse_missing_closing_delimiter() {
602 let input = "---\ntype: jira\nkey: PROJ-1\n";
603 let result = JfmDocument::parse(input);
604 assert!(result.is_err());
605 }
606
607 #[test]
608 fn render_basic_document() {
609 let doc = JfmDocument {
610 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
611 instance: "https://org.atlassian.net".to_string(),
612 key: "PROJ-123".to_string(),
613 project: None,
614 summary: "Fix the bug".to_string(),
615 status: None,
616 issue_type: None,
617 assignee: None,
618 priority: None,
619 labels: vec![],
620 custom_fields: BTreeMap::new(),
621 }),
622 body: "Description here.".to_string(),
623 };
624
625 let output = doc.render().unwrap();
626 assert!(output.starts_with("---\n"));
627 assert!(output.contains("key: PROJ-123"));
628 assert!(output.contains("summary: Fix the bug"));
629 assert!(output.contains("---\n\nDescription here.\n"));
630 }
631
632 #[test]
633 fn render_round_trip() {
634 let doc = JfmDocument {
635 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
636 instance: "https://org.atlassian.net".to_string(),
637 key: "PROJ-789".to_string(),
638 project: None,
639 summary: "Round trip test".to_string(),
640 status: Some("Open".to_string()),
641 issue_type: Some("Bug".to_string()),
642 assignee: None,
643 priority: None,
644 labels: vec!["test".to_string()],
645 custom_fields: BTreeMap::new(),
646 }),
647 body: "# Heading\n\nSome text.\n".to_string(),
648 };
649
650 let rendered = doc.render().unwrap();
651 let restored = JfmDocument::parse(&rendered).unwrap();
652
653 assert_eq!(doc.frontmatter.id(), restored.frontmatter.id());
654 assert_eq!(doc.frontmatter.title(), restored.frontmatter.title());
655 match &restored.frontmatter {
656 JfmFrontmatter::Jira(fm) => {
657 assert_eq!(fm.status.as_deref(), Some("Open"));
658 }
659 _ => panic!("Expected Jira frontmatter"),
660 }
661 assert!(restored.body.contains("# Heading"));
662 assert!(restored.body.contains("Some text."));
663 }
664
665 #[test]
668 fn valid_issue_keys() {
669 assert!(validate_issue_key("PROJ-123").is_ok());
670 assert!(validate_issue_key("AB-1").is_ok());
671 assert!(validate_issue_key("A1B-999").is_ok());
672 }
673
674 #[test]
675 fn invalid_issue_keys() {
676 assert!(validate_issue_key("proj-123").is_err());
677 assert!(validate_issue_key("PROJ").is_err());
678 assert!(validate_issue_key("PROJ-").is_err());
679 assert!(validate_issue_key("-123").is_err());
680 assert!(validate_issue_key("").is_err());
681 }
682
683 fn sample_issue() -> JiraIssue {
686 JiraIssue {
687 key: "TEST-42".to_string(),
688 summary: "Fix the widget".to_string(),
689 description_adf: Some(serde_json::json!({
690 "version": 1,
691 "type": "doc",
692 "content": [{
693 "type": "paragraph",
694 "content": [{"type": "text", "text": "Hello world"}]
695 }]
696 })),
697 status: Some("Open".to_string()),
698 issue_type: Some("Bug".to_string()),
699 assignee: Some("Alice".to_string()),
700 priority: Some("High".to_string()),
701 labels: vec!["backend".to_string()],
702 custom_fields: Vec::new(),
703 }
704 }
705
706 #[test]
707 fn issue_to_jfm_with_description() {
708 let issue = sample_issue();
709 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
710 assert_eq!(doc.frontmatter.id(), "TEST-42");
711 assert_eq!(doc.frontmatter.title(), "Fix the widget");
712 match &doc.frontmatter {
713 JfmFrontmatter::Jira(fm) => {
714 assert_eq!(fm.status.as_deref(), Some("Open"));
715 assert_eq!(fm.issue_type.as_deref(), Some("Bug"));
716 }
717 _ => panic!("Expected Jira frontmatter"),
718 }
719 assert!(doc.body.contains("Hello world"));
720 }
721
722 #[test]
723 fn issue_to_jfm_without_description() {
724 let mut issue = sample_issue();
725 issue.description_adf = None;
726 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
727 assert_eq!(doc.body, "");
728 }
729
730 #[test]
731 fn issue_to_jfm_minimal_fields() {
732 let issue = JiraIssue {
733 key: "MIN-1".to_string(),
734 summary: "Minimal".to_string(),
735 description_adf: None,
736 status: None,
737 issue_type: None,
738 assignee: None,
739 priority: None,
740 labels: vec![],
741 custom_fields: Vec::new(),
742 };
743 let doc = issue_to_jfm_document(&issue, "https://test.atlassian.net").unwrap();
744 assert_eq!(doc.frontmatter.instance(), "https://test.atlassian.net");
745 match &doc.frontmatter {
746 JfmFrontmatter::Jira(fm) => {
747 assert!(fm.status.is_none());
748 assert!(fm.labels.is_empty());
749 }
750 _ => panic!("Expected Jira frontmatter"),
751 }
752 }
753
754 #[test]
755 fn issue_to_jfm_renders_correctly() {
756 let issue = sample_issue();
757 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
758 let rendered = doc.render().unwrap();
759 assert!(rendered.starts_with("---\n"));
760 assert!(rendered.contains("key: TEST-42"));
761 assert!(rendered.contains("Hello world"));
762 }
763
764 #[test]
765 fn render_skips_none_and_empty_fields() {
766 let doc = JfmDocument {
767 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
768 instance: "https://org.atlassian.net".to_string(),
769 key: "PROJ-1".to_string(),
770 project: None,
771 summary: "Minimal".to_string(),
772 status: None,
773 issue_type: None,
774 assignee: None,
775 priority: None,
776 labels: vec![],
777 custom_fields: BTreeMap::new(),
778 }),
779 body: String::new(),
780 };
781
782 let output = doc.render().unwrap();
783 assert!(!output.contains("status:"));
784 assert!(!output.contains("issue_type:"));
785 assert!(!output.contains("labels:"));
786 }
787
788 #[test]
793 fn append_custom_section_adds_leading_newline_when_body_unterminated() {
794 let mut body = String::from("No trailing newline");
795 let field = JiraCustomField {
796 id: "customfield_1".to_string(),
797 name: "AC".to_string(),
798 value: serde_json::Value::Null,
799 };
800 append_custom_section(&mut body, &field, "section body");
801 assert!(body.starts_with("No trailing newline\n\n---\n"));
802 assert!(body.ends_with('\n'));
803 }
804
805 #[test]
806 fn append_custom_section_terminates_body_when_section_lacks_newline() {
807 let mut body = String::from("Main body\n");
808 let field = JiraCustomField {
809 id: "customfield_1".to_string(),
810 name: "AC".to_string(),
811 value: serde_json::Value::Null,
812 };
813 append_custom_section(&mut body, &field, "no-trailing-nl");
814 assert!(body.ends_with("no-trailing-nl\n"));
815 }
816
817 #[test]
818 fn append_custom_section_into_empty_body_has_no_leading_blank_line() {
819 let mut body = String::new();
820 let field = JiraCustomField {
821 id: "customfield_1".to_string(),
822 name: "AC".to_string(),
823 value: serde_json::Value::Null,
824 };
825 append_custom_section(&mut body, &field, "s\n");
826 assert!(body.starts_with("---\n<!-- field: AC (customfield_1) -->\n\ns\n"));
827 }
828
829 #[test]
830 fn jira_custom_fields_returns_none_for_confluence_frontmatter() {
831 let fm = JfmFrontmatter::Confluence(ConfluenceFrontmatter {
832 instance: "https://org.atlassian.net".to_string(),
833 page_id: "1".to_string(),
834 title: "t".to_string(),
835 space_key: "X".to_string(),
836 status: None,
837 version: None,
838 parent_id: None,
839 });
840 assert!(fm.jira_custom_fields().is_none());
841 }
842
843 #[test]
844 fn jira_custom_fields_returns_scalars_for_jira_frontmatter() {
845 let mut custom = BTreeMap::new();
846 custom.insert("K".to_string(), serde_yaml::Value::from("V"));
847 let fm = JfmFrontmatter::Jira(JiraFrontmatter {
848 instance: "https://org.atlassian.net".to_string(),
849 key: "X-1".to_string(),
850 project: None,
851 summary: "s".to_string(),
852 status: None,
853 issue_type: None,
854 assignee: None,
855 priority: None,
856 labels: vec![],
857 custom_fields: custom,
858 });
859 let got = fm.jira_custom_fields().unwrap();
860 assert_eq!(got.len(), 1);
861 assert_eq!(got.get("K").unwrap(), &serde_yaml::Value::from("V"));
862 }
863
864 #[test]
865 fn is_adf_document_detects_doc_shape() {
866 let adf = serde_json::json!({
867 "type": "doc",
868 "version": 1,
869 "content": [{"type": "paragraph", "content": []}]
870 });
871 assert!(is_adf_document(&adf));
872 }
873
874 #[test]
875 fn is_adf_document_rejects_scalar_and_other_objects() {
876 assert!(!is_adf_document(&serde_json::json!("string")));
877 assert!(!is_adf_document(&serde_json::json!(42)));
878 assert!(!is_adf_document(&serde_json::json!({"type": "option"})));
879 assert!(!is_adf_document(&serde_json::json!({
880 "type": "doc", "version": 1
881 })));
882 }
883
884 #[test]
885 fn extract_scalar_passes_through_primitives() {
886 assert_eq!(
887 extract_custom_field_scalar(&serde_json::json!(7)),
888 Some(serde_yaml::Value::from(7_i64))
889 );
890 assert_eq!(
891 extract_custom_field_scalar(&serde_json::json!("hello")),
892 Some(serde_yaml::Value::String("hello".to_string()))
893 );
894 assert_eq!(
895 extract_custom_field_scalar(&serde_json::json!(true)),
896 Some(serde_yaml::Value::Bool(true))
897 );
898 assert_eq!(extract_custom_field_scalar(&serde_json::Value::Null), None);
899 }
900
901 #[test]
902 fn extract_scalar_collapses_option_object_to_value_string() {
903 let value = serde_json::json!({
904 "self": "https://example.atlassian.net/rest/api/3/customFieldOption/12345",
905 "value": "Unplanned",
906 "id": "12345"
907 });
908 assert_eq!(
909 extract_custom_field_scalar(&value),
910 Some(serde_yaml::Value::String("Unplanned".to_string()))
911 );
912 }
913
914 #[test]
915 fn extract_scalar_collapses_user_object_to_display_name() {
916 let value = serde_json::json!({
917 "accountId": "abc123",
918 "displayName": "Alice",
919 "emailAddress": "alice@example.com"
920 });
921 assert_eq!(
922 extract_custom_field_scalar(&value),
923 Some(serde_yaml::Value::String("Alice".to_string()))
924 );
925 }
926
927 #[test]
928 fn extract_scalar_recurses_into_arrays_and_drops_nulls() {
929 let value = serde_json::json!([
930 {"value": "A"},
931 null,
932 {"displayName": "Bob"},
933 42
934 ]);
935 let extracted = extract_custom_field_scalar(&value).unwrap();
936 assert_eq!(
937 extracted,
938 serde_yaml::Value::Sequence(vec![
939 serde_yaml::Value::String("A".to_string()),
940 serde_yaml::Value::String("Bob".to_string()),
941 serde_yaml::Value::from(42_i64),
942 ])
943 );
944 }
945
946 #[test]
947 fn extract_scalar_empty_array_returns_none() {
948 let value = serde_json::json!([null, null]);
949 assert_eq!(extract_custom_field_scalar(&value), None);
950 }
951
952 #[test]
953 fn issue_with_scalar_custom_field_goes_to_frontmatter() {
954 let issue = JiraIssue {
955 key: "ACCS-1".to_string(),
956 summary: "S".to_string(),
957 description_adf: None,
958 status: None,
959 issue_type: None,
960 assignee: None,
961 priority: None,
962 labels: vec![],
963 custom_fields: vec![JiraCustomField {
964 id: "customfield_10001".to_string(),
965 name: "Planned / Unplanned Work".to_string(),
966 value: serde_json::json!({"value": "Unplanned", "id": "42"}),
967 }],
968 };
969 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
970 let rendered = doc.render().unwrap();
971 assert!(rendered.contains("custom_fields:"));
972 assert!(rendered.contains("Planned / Unplanned Work"));
973 assert!(rendered.contains("Unplanned"));
974 assert!(!rendered.contains("<!-- field:"));
975 }
976
977 #[test]
978 fn issue_with_adf_custom_field_becomes_body_section() {
979 let adf_value = serde_json::json!({
980 "type": "doc",
981 "version": 1,
982 "content": [{
983 "type": "paragraph",
984 "content": [{"type": "text", "text": "Criterion one"}]
985 }]
986 });
987 let issue = JiraIssue {
988 key: "ACCS-1".to_string(),
989 summary: "S".to_string(),
990 description_adf: None,
991 status: None,
992 issue_type: None,
993 assignee: None,
994 priority: None,
995 labels: vec![],
996 custom_fields: vec![JiraCustomField {
997 id: "customfield_19300".to_string(),
998 name: "Acceptance Criteria".to_string(),
999 value: adf_value,
1000 }],
1001 };
1002 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1003 let rendered = doc.render().unwrap();
1004 assert!(rendered.contains("<!-- field: Acceptance Criteria (customfield_19300) -->"));
1005 assert!(rendered.contains("Criterion one"));
1006 assert!(!rendered.contains("custom_fields:"));
1007 }
1008
1009 #[test]
1010 fn issue_with_mixed_custom_fields() {
1011 let adf_value = serde_json::json!({
1012 "type": "doc",
1013 "version": 1,
1014 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC body"}]}]
1015 });
1016 let issue = JiraIssue {
1017 key: "ACCS-1".to_string(),
1018 summary: "S".to_string(),
1019 description_adf: Some(serde_json::json!({
1020 "type": "doc",
1021 "version": 1,
1022 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Main"}]}]
1023 })),
1024 status: None,
1025 issue_type: None,
1026 assignee: None,
1027 priority: None,
1028 labels: vec![],
1029 custom_fields: vec![
1030 JiraCustomField {
1031 id: "customfield_19300".to_string(),
1032 name: "Acceptance Criteria".to_string(),
1033 value: adf_value,
1034 },
1035 JiraCustomField {
1036 id: "customfield_10001".to_string(),
1037 name: "Sprint Label".to_string(),
1038 value: serde_json::json!("Q1"),
1039 },
1040 ],
1041 };
1042 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1043 let rendered = doc.render().unwrap();
1044 assert!(rendered.contains("custom_fields:"));
1045 assert!(rendered.contains("Sprint Label: Q1"));
1046 assert!(rendered.contains("Main"));
1047 assert!(rendered.contains("<!-- field: Acceptance Criteria"));
1048 assert!(rendered.contains("AC body"));
1049 }
1050
1051 #[test]
1054 fn split_custom_sections_no_sections_returns_body_unchanged() {
1055 let (body, sections) = split_custom_sections("Hello world\n\nMore text\n");
1056 assert_eq!(body, "Hello world\n\nMore text\n");
1057 assert!(sections.is_empty());
1058 }
1059
1060 #[test]
1061 fn split_custom_sections_extracts_single_section() {
1062 let input = "Main body\n\n---\n<!-- field: Acceptance Criteria (customfield_19300) -->\n\n- Item 1\n- Item 2\n";
1063 let (body, sections) = split_custom_sections(input);
1064 assert_eq!(body, "Main body");
1065 assert_eq!(sections.len(), 1);
1066 assert_eq!(sections[0].name, "Acceptance Criteria");
1067 assert_eq!(sections[0].id, "customfield_19300");
1068 assert_eq!(sections[0].body, "- Item 1\n- Item 2");
1069 }
1070
1071 #[test]
1072 fn split_custom_sections_extracts_multiple_sections() {
1073 let input = "Main\n\n---\n<!-- field: AC (customfield_1) -->\n\nAC body\n\n---\n<!-- field: Notes (customfield_2) -->\n\nNotes body\n";
1074 let (body, sections) = split_custom_sections(input);
1075 assert_eq!(body, "Main");
1076 assert_eq!(sections.len(), 2);
1077 assert_eq!(sections[0].id, "customfield_1");
1078 assert_eq!(sections[0].body, "AC body");
1079 assert_eq!(sections[1].id, "customfield_2");
1080 assert_eq!(sections[1].body, "Notes body");
1081 }
1082
1083 #[test]
1084 fn split_custom_sections_preserves_triple_dashes_inside_body() {
1085 let input =
1088 "Before\n\n---\n\nStill body\n\n---\n<!-- field: AC (customfield_1) -->\n\nSection\n";
1089 let (body, sections) = split_custom_sections(input);
1090 assert!(body.contains("Still body"));
1091 assert_eq!(sections.len(), 1);
1092 assert_eq!(sections[0].body, "Section");
1093 }
1094
1095 #[test]
1096 fn split_custom_sections_body_starting_with_marker() {
1097 let input = "---\n<!-- field: AC (customfield_1) -->\n\nSection body\n";
1098 let (body, sections) = split_custom_sections(input);
1099 assert!(body.is_empty());
1100 assert_eq!(sections.len(), 1);
1101 assert_eq!(sections[0].id, "customfield_1");
1102 assert_eq!(sections[0].body, "Section body");
1103 }
1104
1105 #[test]
1106 fn split_custom_sections_rejects_dash_sequence_without_newline() {
1107 let input = "Before\n---foo\nMore\n---\n<!-- field: AC (customfield_1) -->\n\nS\n";
1109 let (body, sections) = split_custom_sections(input);
1110 assert!(body.contains("---foo"));
1111 assert!(body.contains("More"));
1112 assert_eq!(sections.len(), 1);
1113 }
1114
1115 #[test]
1116 fn split_custom_sections_handles_crlf_line_endings() {
1117 let input = "Main\r\n\r\n---\r\n<!-- field: AC (customfield_1) -->\r\n\r\nSection\r\n";
1118 let (_body, sections) = split_custom_sections(input);
1119 assert_eq!(sections.len(), 1);
1120 assert_eq!(sections[0].name, "AC");
1121 assert_eq!(sections[0].id, "customfield_1");
1122 }
1123
1124 #[test]
1125 fn split_custom_sections_rejects_malformed_field_tag() {
1126 let input = "Before\n\n---\n<!-- not a field tag -->\n\nStill body\n";
1130 let (body, sections) = split_custom_sections(input);
1131 assert!(body.contains("<!-- not a field tag -->"));
1132 assert!(sections.is_empty());
1133 }
1134
1135 #[test]
1136 fn split_custom_sections_roundtrips_through_render() {
1137 let issue = JiraIssue {
1138 key: "TEST-1".to_string(),
1139 summary: "S".to_string(),
1140 description_adf: Some(serde_json::json!({
1141 "type": "doc", "version": 1,
1142 "content": [{"type":"paragraph","content":[{"type":"text","text":"Main"}]}]
1143 })),
1144 status: None,
1145 issue_type: None,
1146 assignee: None,
1147 priority: None,
1148 labels: vec![],
1149 custom_fields: vec![JiraCustomField {
1150 id: "customfield_19300".to_string(),
1151 name: "Acceptance Criteria".to_string(),
1152 value: serde_json::json!({
1153 "type": "doc", "version": 1,
1154 "content": [{"type":"paragraph","content":[{"type":"text","text":"AC line"}]}]
1155 }),
1156 }],
1157 };
1158 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1159 let rendered = doc.render().unwrap();
1160 let reparsed = JfmDocument::parse(&rendered).unwrap();
1161 let (body, sections) = reparsed.split_custom_sections();
1162 assert!(body.contains("Main"));
1163 assert_eq!(sections.len(), 1);
1164 assert_eq!(sections[0].id, "customfield_19300");
1165 assert_eq!(sections[0].name, "Acceptance Criteria");
1166 assert!(sections[0].body.contains("AC line"));
1167 }
1168
1169 #[test]
1170 fn issue_with_null_custom_field_is_omitted() {
1171 let issue = JiraIssue {
1172 key: "ACCS-1".to_string(),
1173 summary: "S".to_string(),
1174 description_adf: None,
1175 status: None,
1176 issue_type: None,
1177 assignee: None,
1178 priority: None,
1179 labels: vec![],
1180 custom_fields: vec![JiraCustomField {
1181 id: "customfield_99".to_string(),
1182 name: "Empty Field".to_string(),
1183 value: serde_json::Value::Null,
1184 }],
1185 };
1186 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1187 let rendered = doc.render().unwrap();
1188 assert!(!rendered.contains("custom_fields:"));
1189 assert!(!rendered.contains("Empty Field"));
1190 }
1191
1192 #[test]
1195 fn parse_confluence_document() {
1196 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";
1197 let doc = JfmDocument::parse(input).unwrap();
1198 assert_eq!(doc.frontmatter.doc_type(), "confluence");
1199 assert_eq!(doc.frontmatter.id(), "12345");
1200 assert_eq!(doc.frontmatter.title(), "Architecture Overview");
1201 match &doc.frontmatter {
1202 JfmFrontmatter::Confluence(fm) => {
1203 assert_eq!(fm.space_key, "ENG");
1204 assert_eq!(fm.status.as_deref(), Some("current"));
1205 assert_eq!(fm.version, Some(7));
1206 }
1207 _ => panic!("Expected Confluence frontmatter"),
1208 }
1209 }
1210
1211 #[test]
1212 fn render_confluence_document() {
1213 let doc = JfmDocument {
1214 frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
1215 instance: "https://org.atlassian.net".to_string(),
1216 page_id: "12345".to_string(),
1217 title: "Architecture Overview".to_string(),
1218 space_key: "ENG".to_string(),
1219 status: Some("current".to_string()),
1220 version: Some(7),
1221 parent_id: None,
1222 }),
1223 body: "Page body here.\n".to_string(),
1224 };
1225
1226 let output = doc.render().unwrap();
1227 assert!(output.starts_with("---\n"));
1228 assert!(output.contains("type: confluence"));
1229 assert!(output.contains("page_id:"));
1230 assert!(output.contains("space_key: ENG"));
1231 assert!(output.contains("Page body here."));
1232 }
1233
1234 #[test]
1235 fn confluence_round_trip() {
1236 let doc = JfmDocument {
1237 frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
1238 instance: "https://org.atlassian.net".to_string(),
1239 page_id: "99999".to_string(),
1240 title: "Round trip".to_string(),
1241 space_key: "DEV".to_string(),
1242 status: None,
1243 version: Some(3),
1244 parent_id: Some("88888".to_string()),
1245 }),
1246 body: "Content.\n".to_string(),
1247 };
1248
1249 let rendered = doc.render().unwrap();
1250 let restored = JfmDocument::parse(&rendered).unwrap();
1251 assert_eq!(restored.frontmatter.id(), "99999");
1252 assert_eq!(restored.frontmatter.title(), "Round trip");
1253 match &restored.frontmatter {
1254 JfmFrontmatter::Confluence(fm) => {
1255 assert_eq!(fm.space_key, "DEV");
1256 assert_eq!(fm.version, Some(3));
1257 assert_eq!(fm.parent_id.as_deref(), Some("88888"));
1258 }
1259 _ => panic!("Expected Confluence frontmatter"),
1260 }
1261 }
1262
1263 #[test]
1266 fn content_item_jira_to_document() {
1267 let item = ContentItem {
1268 id: "PROJ-42".to_string(),
1269 title: "A JIRA issue".to_string(),
1270 body_adf: Some(serde_json::json!({
1271 "version": 1,
1272 "type": "doc",
1273 "content": [{
1274 "type": "paragraph",
1275 "content": [{"type": "text", "text": "Content"}]
1276 }]
1277 })),
1278 metadata: ContentMetadata::Jira {
1279 status: Some("Open".to_string()),
1280 issue_type: Some("Bug".to_string()),
1281 assignee: None,
1282 priority: None,
1283 labels: vec![],
1284 },
1285 };
1286 let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
1287 assert_eq!(doc.frontmatter.doc_type(), "jira");
1288 assert_eq!(doc.frontmatter.id(), "PROJ-42");
1289 assert!(doc.body.contains("Content"));
1290 }
1291
1292 #[test]
1293 fn content_item_confluence_to_document() {
1294 let item = ContentItem {
1295 id: "12345".to_string(),
1296 title: "A Confluence page".to_string(),
1297 body_adf: None,
1298 metadata: ContentMetadata::Confluence {
1299 space_key: "ENG".to_string(),
1300 status: Some("current".to_string()),
1301 version: Some(5),
1302 parent_id: None,
1303 },
1304 };
1305 let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
1306 assert_eq!(doc.frontmatter.doc_type(), "confluence");
1307 assert_eq!(doc.frontmatter.id(), "12345");
1308 assert_eq!(doc.frontmatter.title(), "A Confluence page");
1309 }
1310}