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, Default, Deserialize)]
107#[serde(default)]
108pub struct JiraCreateFrontmatter {
109 pub r#type: Option<String>,
111
112 pub key: String,
115
116 pub project: Option<String>,
118
119 pub summary: Option<String>,
121
122 pub issue_type: Option<String>,
124
125 pub labels: Vec<String>,
127
128 pub custom_fields: BTreeMap<String, serde_yaml::Value>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ConfluenceFrontmatter {
135 pub instance: String,
137
138 #[serde(default)]
140 pub page_id: String,
141
142 pub title: String,
144
145 pub space_key: String,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub status: Option<String>,
151
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub version: Option<u32>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub parent_id: Option<String>,
159}
160
161impl JfmFrontmatter {
162 pub fn instance(&self) -> &str {
164 match self {
165 Self::Jira(fm) => &fm.instance,
166 Self::Confluence(fm) => &fm.instance,
167 }
168 }
169
170 pub fn id(&self) -> &str {
172 match self {
173 Self::Jira(fm) => &fm.key,
174 Self::Confluence(fm) => &fm.page_id,
175 }
176 }
177
178 pub fn title(&self) -> &str {
180 match self {
181 Self::Jira(fm) => &fm.summary,
182 Self::Confluence(fm) => &fm.title,
183 }
184 }
185
186 pub fn doc_type(&self) -> &str {
188 match self {
189 Self::Jira(_) => "jira",
190 Self::Confluence(_) => "confluence",
191 }
192 }
193
194 pub fn jira_custom_fields(&self) -> Option<&BTreeMap<String, serde_yaml::Value>> {
197 match self {
198 Self::Jira(fm) => Some(&fm.custom_fields),
199 Self::Confluence(_) => None,
200 }
201 }
202}
203
204pub fn validate_issue_key(key: &str) -> Result<()> {
206 let re =
207 regex::Regex::new(r"^[A-Z][A-Z0-9]+-\d+$").context("Failed to compile issue key regex")?;
208 if !re.is_match(key) {
209 anyhow::bail!("Invalid JIRA issue key: '{key}'. Expected format: PROJ-123");
210 }
211 Ok(())
212}
213
214pub fn issue_to_jfm_document(issue: &JiraIssue, instance_url: &str) -> Result<JfmDocument> {
222 let mut body = if let Some(ref adf_value) = issue.description_adf {
223 let adf_doc: AdfDocument =
224 serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
225 adf_to_markdown(&adf_doc)?
226 } else {
227 String::new()
228 };
229
230 let mut custom_scalars: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
231 for field in &issue.custom_fields {
232 render_custom_field(field, &mut body, &mut custom_scalars)?;
233 }
234
235 Ok(JfmDocument {
236 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
237 instance: instance_url.to_string(),
238 key: issue.key.clone(),
239 project: None,
240 summary: issue.summary.clone(),
241 status: issue.status.clone(),
242 issue_type: issue.issue_type.clone(),
243 assignee: issue.assignee.clone(),
244 priority: issue.priority.clone(),
245 labels: issue.labels.clone(),
246 custom_fields: custom_scalars,
247 }),
248 body,
249 })
250}
251
252fn render_custom_field(
255 field: &JiraCustomField,
256 body: &mut String,
257 scalars: &mut BTreeMap<String, serde_yaml::Value>,
258) -> Result<()> {
259 if is_adf_document(&field.value) {
260 let adf_doc: AdfDocument = serde_json::from_value(field.value.clone())
261 .with_context(|| format!("Failed to parse ADF value for {}", field.id))?;
262 let section_md = adf_to_markdown(&adf_doc)?;
263 append_custom_section(body, field, §ion_md);
264 } else if let Some(scalar) = extract_custom_field_scalar(&field.value) {
265 scalars.insert(field.name.clone(), scalar);
266 }
267 Ok(())
269}
270
271fn append_custom_section(body: &mut String, field: &JiraCustomField, section_md: &str) {
273 if !body.is_empty() && !body.ends_with('\n') {
274 body.push('\n');
275 }
276 if !body.is_empty() {
277 body.push('\n');
278 }
279 let _ = write!(
280 body,
281 "---\n<!-- field: {} ({}) -->\n\n{}",
282 field.name, field.id, section_md
283 );
284 if !body.ends_with('\n') {
285 body.push('\n');
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CustomFieldSection {
296 pub name: String,
298
299 pub id: String,
301
302 pub body: String,
304}
305
306pub(crate) fn split_custom_sections(body: &str) -> (String, Vec<CustomFieldSection>) {
315 let mut markers: Vec<(usize, usize, String, String)> = Vec::new();
317 let mut cursor = 0;
318
319 while cursor < body.len() {
320 let Some(marker_start) = find_next_marker(body, cursor) else {
321 break;
322 };
323
324 let after_dashes = marker_start + 3;
326 let after_nl = if body[after_dashes..].starts_with("\r\n") {
327 after_dashes + 2
328 } else if body[after_dashes..].starts_with('\n') {
329 after_dashes + 1
330 } else {
331 cursor = after_dashes;
332 continue;
333 };
334
335 if let Some((name, id, content_start)) = parse_field_tag_line(body, after_nl) {
336 markers.push((marker_start, content_start, name, id));
337 cursor = content_start;
338 } else {
339 cursor = after_nl;
340 }
341 }
342
343 if markers.is_empty() {
344 return (body.to_string(), Vec::new());
345 }
346
347 let first_marker = markers[0].0;
348 let main_body = body[..first_marker].trim_end_matches('\n').to_string();
349
350 let mut sections = Vec::with_capacity(markers.len());
351 for i in 0..markers.len() {
352 let (_marker, content_start, name, id) = &markers[i];
353 let content_end = markers.get(i + 1).map_or(body.len(), |next| next.0);
354 let raw = &body[*content_start..content_end];
355 let trimmed = raw.trim_matches('\n').to_string();
356 sections.push(CustomFieldSection {
357 name: name.clone(),
358 id: id.clone(),
359 body: trimmed,
360 });
361 }
362
363 (main_body, sections)
364}
365
366fn find_next_marker(body: &str, from: usize) -> Option<usize> {
369 if from == 0 && body.starts_with("---") {
370 return Some(0);
371 }
372 body[from..]
373 .find("\n---")
374 .map(|rel| from + rel + 1)
375 .filter(|p| *p + 3 <= body.len())
376}
377
378fn parse_field_tag_line(body: &str, start: usize) -> Option<(String, String, usize)> {
382 let rest = body.get(start..)?;
383 let line_end = rest.find('\n').unwrap_or(rest.len());
384 let line = rest[..line_end].trim_end_matches('\r');
385
386 let after_open = line.strip_prefix("<!--")?.trim_start();
387 let after_field = after_open.strip_prefix("field:")?.trim_start();
388 let close_idx = after_field.rfind("-->")?;
389 let inner = after_field[..close_idx].trim_end();
390
391 let paren_open = inner.rfind('(')?;
392 let name = inner[..paren_open].trim().to_string();
393 let rest_part = inner.get(paren_open + 1..)?;
394 let paren_close = rest_part.rfind(')')?;
395 let id = rest_part[..paren_close].trim().to_string();
396
397 if name.is_empty() || id.is_empty() {
398 return None;
399 }
400
401 let next_line_start = (start + line_end + 1).min(body.len());
402 Some((name, id, next_line_start))
403}
404
405fn is_adf_document(value: &serde_json::Value) -> bool {
408 let Some(obj) = value.as_object() else {
409 return false;
410 };
411 obj.get("type").and_then(|t| t.as_str()) == Some("doc")
412 && obj.contains_key("version")
413 && obj.contains_key("content")
414}
415
416fn extract_custom_field_scalar(value: &serde_json::Value) -> Option<serde_yaml::Value> {
427 use serde_json::Value as J;
428 match value {
429 J::Null => None,
430 J::Bool(_) | J::Number(_) | J::String(_) => json_to_yaml(value),
431 J::Array(items) => {
432 let extracted: Vec<_> = items
433 .iter()
434 .filter_map(extract_custom_field_scalar)
435 .collect();
436 if extracted.is_empty() {
437 None
438 } else {
439 Some(serde_yaml::Value::Sequence(extracted))
440 }
441 }
442 J::Object(map) => {
443 if let Some(v) = map.get("value").and_then(|v| v.as_str()) {
444 Some(serde_yaml::Value::String(v.to_string()))
445 } else if let Some(name) = map.get("displayName").and_then(|v| v.as_str()) {
446 Some(serde_yaml::Value::String(name.to_string()))
447 } else {
448 json_to_yaml(value)
449 }
450 }
451 }
452}
453
454fn json_to_yaml(value: &serde_json::Value) -> Option<serde_yaml::Value> {
455 serde_yaml::to_value(value).ok()
456}
457
458pub fn content_item_to_document(item: &ContentItem, instance_url: &str) -> Result<JfmDocument> {
463 let body = if let Some(ref adf_value) = item.body_adf {
464 let adf_doc: AdfDocument =
465 serde_json::from_value(adf_value.clone()).context("Failed to parse ADF description")?;
466 adf_to_markdown(&adf_doc)?
467 } else {
468 String::new()
469 };
470
471 let frontmatter = match &item.metadata {
472 ContentMetadata::Jira {
473 status,
474 issue_type,
475 assignee,
476 priority,
477 labels,
478 } => JfmFrontmatter::Jira(JiraFrontmatter {
479 instance: instance_url.to_string(),
480 key: item.id.clone(),
481 project: None,
482 summary: item.title.clone(),
483 status: status.clone(),
484 issue_type: issue_type.clone(),
485 assignee: assignee.clone(),
486 priority: priority.clone(),
487 labels: labels.clone(),
488 custom_fields: BTreeMap::new(),
489 }),
490 ContentMetadata::Confluence {
491 space_key,
492 status,
493 version,
494 parent_id,
495 } => JfmFrontmatter::Confluence(ConfluenceFrontmatter {
496 instance: instance_url.to_string(),
497 page_id: item.id.clone(),
498 title: item.title.clone(),
499 space_key: space_key.clone(),
500 status: status.clone(),
501 version: *version,
502 parent_id: parent_id.clone(),
503 }),
504 };
505
506 Ok(JfmDocument { frontmatter, body })
507}
508
509pub(crate) fn split_frontmatter(input: &str) -> Result<(Option<&str>, String)> {
522 let trimmed = input.trim_start();
523
524 if !trimmed.starts_with("---") {
525 return Ok((None, input.to_string()));
526 }
527
528 let after_opening = &trimmed[3..];
530 let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening);
531
532 let closing_pos = after_opening.find("\n---").ok_or_else(|| {
533 AtlassianError::InvalidDocument("Missing closing '---' frontmatter delimiter".to_string())
534 })?;
535
536 let frontmatter_yaml = &after_opening[..closing_pos];
537 let after_closing = &after_opening[closing_pos + 4..]; let body = after_closing
541 .strip_prefix('\n')
542 .unwrap_or(after_closing)
543 .to_string();
544
545 Ok((Some(frontmatter_yaml), body))
546}
547
548impl JfmDocument {
549 pub fn parse(input: &str) -> Result<Self> {
553 let (frontmatter_yaml, body) = split_frontmatter(input)?;
554 let frontmatter_yaml = frontmatter_yaml.ok_or_else(|| {
555 AtlassianError::InvalidDocument(
556 "Document must start with '---' frontmatter delimiter".to_string(),
557 )
558 })?;
559
560 let frontmatter: JfmFrontmatter = serde_yaml::from_str(frontmatter_yaml)
561 .context("Failed to parse JFM frontmatter YAML")?;
562
563 Ok(Self { frontmatter, body })
564 }
565
566 pub fn render(&self) -> Result<String> {
568 let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)
569 .context("Failed to serialize JFM frontmatter to YAML")?;
570
571 let mut output = String::new();
572 output.push_str("---\n");
573 output.push_str(&frontmatter_yaml);
574 output.push_str("---\n");
575 if !self.body.is_empty() {
576 output.push('\n');
577 output.push_str(&self.body);
578 if !self.body.ends_with('\n') {
580 output.push('\n');
581 }
582 }
583
584 Ok(output)
585 }
586
587 pub fn split_custom_sections(&self) -> (String, Vec<CustomFieldSection>) {
593 split_custom_sections(&self.body)
594 }
595}
596
597#[cfg(test)]
598#[allow(
599 clippy::unwrap_used,
600 clippy::expect_used,
601 clippy::match_wildcard_for_single_variants
602)]
603mod tests {
604 use super::*;
605
606 #[test]
607 fn parse_basic_document() {
608 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Fix the bug\n---\n\nThis is the description.\n";
609 let doc = JfmDocument::parse(input).unwrap();
610 assert_eq!(doc.frontmatter.doc_type(), "jira");
611 assert_eq!(doc.frontmatter.id(), "PROJ-123");
612 assert_eq!(doc.frontmatter.title(), "Fix the bug");
613 assert_eq!(doc.body, "\nThis is the description.\n");
614 }
615
616 #[test]
617 fn parse_with_optional_fields() {
618 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";
619 let doc = JfmDocument::parse(input).unwrap();
620 match &doc.frontmatter {
621 JfmFrontmatter::Jira(fm) => {
622 assert_eq!(fm.status.as_deref(), Some("In Progress"));
623 assert_eq!(fm.issue_type.as_deref(), Some("Story"));
624 assert_eq!(fm.assignee.as_deref(), Some("Alice"));
625 assert_eq!(fm.priority.as_deref(), Some("High"));
626 assert_eq!(fm.labels, vec!["backend", "auth"]);
627 }
628 _ => panic!("Expected Jira frontmatter"),
629 }
630 }
631
632 #[test]
633 fn parse_empty_body() {
634 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Empty\n---\n";
635 let doc = JfmDocument::parse(input).unwrap();
636 assert_eq!(doc.body, "");
637 }
638
639 #[test]
640 fn parse_body_with_triple_dashes() {
641 let input = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Dashes\n---\n\nContent with --- dashes in it.\n";
642 let doc = JfmDocument::parse(input).unwrap();
643 assert!(doc.body.contains("--- dashes"));
644 }
645
646 #[test]
647 fn parse_missing_opening_delimiter() {
648 let input = "type: jira\nkey: PROJ-1\n";
649 let result = JfmDocument::parse(input);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn parse_missing_closing_delimiter() {
655 let input = "---\ntype: jira\nkey: PROJ-1\n";
656 let result = JfmDocument::parse(input);
657 assert!(result.is_err());
658 }
659
660 #[test]
663 fn split_frontmatter_with_block() {
664 let input = "---\nsummary: T\n---\nBody text\n";
665 let (yaml, body) = split_frontmatter(input).unwrap();
666 assert_eq!(yaml, Some("summary: T"));
667 assert_eq!(body, "Body text\n");
668 }
669
670 #[test]
671 fn split_frontmatter_without_block_returns_whole_input_as_body() {
672 let input = "Just a plain markdown body.\n";
673 let (yaml, body) = split_frontmatter(input).unwrap();
674 assert!(yaml.is_none());
675 assert_eq!(body, "Just a plain markdown body.\n");
676 }
677
678 #[test]
679 fn split_frontmatter_missing_close_errors() {
680 let input = "---\nsummary: T\n\nBody without a closing delimiter\n";
681 let err = split_frontmatter(input).unwrap_err();
682 assert!(err.to_string().contains("Missing closing"));
683 }
684
685 #[test]
688 fn jira_create_frontmatter_tolerates_round_tripped_fields() {
689 let yaml = "type: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nproject: PROJ\nsummary: Title\nstatus: Open\nassignee: alice\nlabels:\n - a\n";
692 let fm: JiraCreateFrontmatter = serde_yaml::from_str(yaml).unwrap();
693 assert_eq!(fm.r#type.as_deref(), Some("jira"));
694 assert_eq!(fm.project.as_deref(), Some("PROJ"));
695 assert_eq!(fm.summary.as_deref(), Some("Title"));
696 assert_eq!(fm.labels, vec!["a"]);
697 }
698
699 #[test]
700 fn jira_create_frontmatter_all_fields_optional() {
701 let fm: JiraCreateFrontmatter = serde_yaml::from_str("{}").unwrap();
702 assert!(fm.r#type.is_none());
703 assert!(fm.project.is_none());
704 assert!(fm.summary.is_none());
705 assert!(fm.labels.is_empty());
706 }
707
708 #[test]
709 fn render_basic_document() {
710 let doc = JfmDocument {
711 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
712 instance: "https://org.atlassian.net".to_string(),
713 key: "PROJ-123".to_string(),
714 project: None,
715 summary: "Fix the bug".to_string(),
716 status: None,
717 issue_type: None,
718 assignee: None,
719 priority: None,
720 labels: vec![],
721 custom_fields: BTreeMap::new(),
722 }),
723 body: "Description here.".to_string(),
724 };
725
726 let output = doc.render().unwrap();
727 assert!(output.starts_with("---\n"));
728 assert!(output.contains("key: PROJ-123"));
729 assert!(output.contains("summary: Fix the bug"));
730 assert!(output.contains("---\n\nDescription here.\n"));
731 }
732
733 #[test]
734 fn render_round_trip() {
735 let doc = JfmDocument {
736 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
737 instance: "https://org.atlassian.net".to_string(),
738 key: "PROJ-789".to_string(),
739 project: None,
740 summary: "Round trip test".to_string(),
741 status: Some("Open".to_string()),
742 issue_type: Some("Bug".to_string()),
743 assignee: None,
744 priority: None,
745 labels: vec!["test".to_string()],
746 custom_fields: BTreeMap::new(),
747 }),
748 body: "# Heading\n\nSome text.\n".to_string(),
749 };
750
751 let rendered = doc.render().unwrap();
752 let restored = JfmDocument::parse(&rendered).unwrap();
753
754 assert_eq!(doc.frontmatter.id(), restored.frontmatter.id());
755 assert_eq!(doc.frontmatter.title(), restored.frontmatter.title());
756 match &restored.frontmatter {
757 JfmFrontmatter::Jira(fm) => {
758 assert_eq!(fm.status.as_deref(), Some("Open"));
759 }
760 _ => panic!("Expected Jira frontmatter"),
761 }
762 assert!(restored.body.contains("# Heading"));
763 assert!(restored.body.contains("Some text."));
764 }
765
766 #[test]
769 fn valid_issue_keys() {
770 assert!(validate_issue_key("PROJ-123").is_ok());
771 assert!(validate_issue_key("AB-1").is_ok());
772 assert!(validate_issue_key("A1B-999").is_ok());
773 }
774
775 #[test]
776 fn invalid_issue_keys() {
777 assert!(validate_issue_key("proj-123").is_err());
778 assert!(validate_issue_key("PROJ").is_err());
779 assert!(validate_issue_key("PROJ-").is_err());
780 assert!(validate_issue_key("-123").is_err());
781 assert!(validate_issue_key("").is_err());
782 }
783
784 fn sample_issue() -> JiraIssue {
787 JiraIssue {
788 key: "TEST-42".to_string(),
789 summary: "Fix the widget".to_string(),
790 description_adf: Some(serde_json::json!({
791 "version": 1,
792 "type": "doc",
793 "content": [{
794 "type": "paragraph",
795 "content": [{"type": "text", "text": "Hello world"}]
796 }]
797 })),
798 status: Some("Open".to_string()),
799 issue_type: Some("Bug".to_string()),
800 assignee: Some("Alice".to_string()),
801 priority: Some("High".to_string()),
802 labels: vec!["backend".to_string()],
803 custom_fields: Vec::new(),
804 }
805 }
806
807 #[test]
808 fn issue_to_jfm_with_description() {
809 let issue = sample_issue();
810 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
811 assert_eq!(doc.frontmatter.id(), "TEST-42");
812 assert_eq!(doc.frontmatter.title(), "Fix the widget");
813 match &doc.frontmatter {
814 JfmFrontmatter::Jira(fm) => {
815 assert_eq!(fm.status.as_deref(), Some("Open"));
816 assert_eq!(fm.issue_type.as_deref(), Some("Bug"));
817 }
818 _ => panic!("Expected Jira frontmatter"),
819 }
820 assert!(doc.body.contains("Hello world"));
821 }
822
823 #[test]
824 fn issue_to_jfm_without_description() {
825 let mut issue = sample_issue();
826 issue.description_adf = None;
827 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
828 assert_eq!(doc.body, "");
829 }
830
831 #[test]
832 fn issue_to_jfm_minimal_fields() {
833 let issue = JiraIssue {
834 key: "MIN-1".to_string(),
835 summary: "Minimal".to_string(),
836 description_adf: None,
837 status: None,
838 issue_type: None,
839 assignee: None,
840 priority: None,
841 labels: vec![],
842 custom_fields: Vec::new(),
843 };
844 let doc = issue_to_jfm_document(&issue, "https://test.atlassian.net").unwrap();
845 assert_eq!(doc.frontmatter.instance(), "https://test.atlassian.net");
846 match &doc.frontmatter {
847 JfmFrontmatter::Jira(fm) => {
848 assert!(fm.status.is_none());
849 assert!(fm.labels.is_empty());
850 }
851 _ => panic!("Expected Jira frontmatter"),
852 }
853 }
854
855 #[test]
856 fn issue_to_jfm_renders_correctly() {
857 let issue = sample_issue();
858 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
859 let rendered = doc.render().unwrap();
860 assert!(rendered.starts_with("---\n"));
861 assert!(rendered.contains("key: TEST-42"));
862 assert!(rendered.contains("Hello world"));
863 }
864
865 #[test]
866 fn render_skips_none_and_empty_fields() {
867 let doc = JfmDocument {
868 frontmatter: JfmFrontmatter::Jira(JiraFrontmatter {
869 instance: "https://org.atlassian.net".to_string(),
870 key: "PROJ-1".to_string(),
871 project: None,
872 summary: "Minimal".to_string(),
873 status: None,
874 issue_type: None,
875 assignee: None,
876 priority: None,
877 labels: vec![],
878 custom_fields: BTreeMap::new(),
879 }),
880 body: String::new(),
881 };
882
883 let output = doc.render().unwrap();
884 assert!(!output.contains("status:"));
885 assert!(!output.contains("issue_type:"));
886 assert!(!output.contains("labels:"));
887 }
888
889 #[test]
894 fn append_custom_section_adds_leading_newline_when_body_unterminated() {
895 let mut body = String::from("No trailing newline");
896 let field = JiraCustomField {
897 id: "customfield_1".to_string(),
898 name: "AC".to_string(),
899 value: serde_json::Value::Null,
900 };
901 append_custom_section(&mut body, &field, "section body");
902 assert!(body.starts_with("No trailing newline\n\n---\n"));
903 assert!(body.ends_with('\n'));
904 }
905
906 #[test]
907 fn append_custom_section_terminates_body_when_section_lacks_newline() {
908 let mut body = String::from("Main body\n");
909 let field = JiraCustomField {
910 id: "customfield_1".to_string(),
911 name: "AC".to_string(),
912 value: serde_json::Value::Null,
913 };
914 append_custom_section(&mut body, &field, "no-trailing-nl");
915 assert!(body.ends_with("no-trailing-nl\n"));
916 }
917
918 #[test]
919 fn append_custom_section_into_empty_body_has_no_leading_blank_line() {
920 let mut body = String::new();
921 let field = JiraCustomField {
922 id: "customfield_1".to_string(),
923 name: "AC".to_string(),
924 value: serde_json::Value::Null,
925 };
926 append_custom_section(&mut body, &field, "s\n");
927 assert!(body.starts_with("---\n<!-- field: AC (customfield_1) -->\n\ns\n"));
928 }
929
930 #[test]
931 fn jira_custom_fields_returns_none_for_confluence_frontmatter() {
932 let fm = JfmFrontmatter::Confluence(ConfluenceFrontmatter {
933 instance: "https://org.atlassian.net".to_string(),
934 page_id: "1".to_string(),
935 title: "t".to_string(),
936 space_key: "X".to_string(),
937 status: None,
938 version: None,
939 parent_id: None,
940 });
941 assert!(fm.jira_custom_fields().is_none());
942 }
943
944 #[test]
945 fn jira_custom_fields_returns_scalars_for_jira_frontmatter() {
946 let mut custom = BTreeMap::new();
947 custom.insert("K".to_string(), serde_yaml::Value::from("V"));
948 let fm = JfmFrontmatter::Jira(JiraFrontmatter {
949 instance: "https://org.atlassian.net".to_string(),
950 key: "X-1".to_string(),
951 project: None,
952 summary: "s".to_string(),
953 status: None,
954 issue_type: None,
955 assignee: None,
956 priority: None,
957 labels: vec![],
958 custom_fields: custom,
959 });
960 let got = fm.jira_custom_fields().unwrap();
961 assert_eq!(got.len(), 1);
962 assert_eq!(got.get("K").unwrap(), &serde_yaml::Value::from("V"));
963 }
964
965 #[test]
966 fn is_adf_document_detects_doc_shape() {
967 let adf = serde_json::json!({
968 "type": "doc",
969 "version": 1,
970 "content": [{"type": "paragraph", "content": []}]
971 });
972 assert!(is_adf_document(&adf));
973 }
974
975 #[test]
976 fn is_adf_document_rejects_scalar_and_other_objects() {
977 assert!(!is_adf_document(&serde_json::json!("string")));
978 assert!(!is_adf_document(&serde_json::json!(42)));
979 assert!(!is_adf_document(&serde_json::json!({"type": "option"})));
980 assert!(!is_adf_document(&serde_json::json!({
981 "type": "doc", "version": 1
982 })));
983 }
984
985 #[test]
986 fn extract_scalar_passes_through_primitives() {
987 assert_eq!(
988 extract_custom_field_scalar(&serde_json::json!(7)),
989 Some(serde_yaml::Value::from(7_i64))
990 );
991 assert_eq!(
992 extract_custom_field_scalar(&serde_json::json!("hello")),
993 Some(serde_yaml::Value::String("hello".to_string()))
994 );
995 assert_eq!(
996 extract_custom_field_scalar(&serde_json::json!(true)),
997 Some(serde_yaml::Value::Bool(true))
998 );
999 assert_eq!(extract_custom_field_scalar(&serde_json::Value::Null), None);
1000 }
1001
1002 #[test]
1003 fn extract_scalar_collapses_option_object_to_value_string() {
1004 let value = serde_json::json!({
1005 "self": "https://example.atlassian.net/rest/api/3/customFieldOption/12345",
1006 "value": "Unplanned",
1007 "id": "12345"
1008 });
1009 assert_eq!(
1010 extract_custom_field_scalar(&value),
1011 Some(serde_yaml::Value::String("Unplanned".to_string()))
1012 );
1013 }
1014
1015 #[test]
1016 fn extract_scalar_collapses_user_object_to_display_name() {
1017 let value = serde_json::json!({
1018 "accountId": "abc123",
1019 "displayName": "Alice",
1020 "emailAddress": "alice@example.com"
1021 });
1022 assert_eq!(
1023 extract_custom_field_scalar(&value),
1024 Some(serde_yaml::Value::String("Alice".to_string()))
1025 );
1026 }
1027
1028 #[test]
1029 fn extract_scalar_recurses_into_arrays_and_drops_nulls() {
1030 let value = serde_json::json!([
1031 {"value": "A"},
1032 null,
1033 {"displayName": "Bob"},
1034 42
1035 ]);
1036 let extracted = extract_custom_field_scalar(&value).unwrap();
1037 assert_eq!(
1038 extracted,
1039 serde_yaml::Value::Sequence(vec![
1040 serde_yaml::Value::String("A".to_string()),
1041 serde_yaml::Value::String("Bob".to_string()),
1042 serde_yaml::Value::from(42_i64),
1043 ])
1044 );
1045 }
1046
1047 #[test]
1048 fn extract_scalar_empty_array_returns_none() {
1049 let value = serde_json::json!([null, null]);
1050 assert_eq!(extract_custom_field_scalar(&value), None);
1051 }
1052
1053 #[test]
1054 fn issue_with_scalar_custom_field_goes_to_frontmatter() {
1055 let issue = JiraIssue {
1056 key: "ACCS-1".to_string(),
1057 summary: "S".to_string(),
1058 description_adf: None,
1059 status: None,
1060 issue_type: None,
1061 assignee: None,
1062 priority: None,
1063 labels: vec![],
1064 custom_fields: vec![JiraCustomField {
1065 id: "customfield_10001".to_string(),
1066 name: "Planned / Unplanned Work".to_string(),
1067 value: serde_json::json!({"value": "Unplanned", "id": "42"}),
1068 }],
1069 };
1070 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1071 let rendered = doc.render().unwrap();
1072 assert!(rendered.contains("custom_fields:"));
1073 assert!(rendered.contains("Planned / Unplanned Work"));
1074 assert!(rendered.contains("Unplanned"));
1075 assert!(!rendered.contains("<!-- field:"));
1076 }
1077
1078 #[test]
1079 fn issue_with_adf_custom_field_becomes_body_section() {
1080 let adf_value = serde_json::json!({
1081 "type": "doc",
1082 "version": 1,
1083 "content": [{
1084 "type": "paragraph",
1085 "content": [{"type": "text", "text": "Criterion one"}]
1086 }]
1087 });
1088 let issue = JiraIssue {
1089 key: "ACCS-1".to_string(),
1090 summary: "S".to_string(),
1091 description_adf: None,
1092 status: None,
1093 issue_type: None,
1094 assignee: None,
1095 priority: None,
1096 labels: vec![],
1097 custom_fields: vec![JiraCustomField {
1098 id: "customfield_19300".to_string(),
1099 name: "Acceptance Criteria".to_string(),
1100 value: adf_value,
1101 }],
1102 };
1103 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1104 let rendered = doc.render().unwrap();
1105 assert!(rendered.contains("<!-- field: Acceptance Criteria (customfield_19300) -->"));
1106 assert!(rendered.contains("Criterion one"));
1107 assert!(!rendered.contains("custom_fields:"));
1108 }
1109
1110 #[test]
1111 fn issue_with_mixed_custom_fields() {
1112 let adf_value = serde_json::json!({
1113 "type": "doc",
1114 "version": 1,
1115 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "AC body"}]}]
1116 });
1117 let issue = JiraIssue {
1118 key: "ACCS-1".to_string(),
1119 summary: "S".to_string(),
1120 description_adf: Some(serde_json::json!({
1121 "type": "doc",
1122 "version": 1,
1123 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Main"}]}]
1124 })),
1125 status: None,
1126 issue_type: None,
1127 assignee: None,
1128 priority: None,
1129 labels: vec![],
1130 custom_fields: vec![
1131 JiraCustomField {
1132 id: "customfield_19300".to_string(),
1133 name: "Acceptance Criteria".to_string(),
1134 value: adf_value,
1135 },
1136 JiraCustomField {
1137 id: "customfield_10001".to_string(),
1138 name: "Sprint Label".to_string(),
1139 value: serde_json::json!("Q1"),
1140 },
1141 ],
1142 };
1143 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1144 let rendered = doc.render().unwrap();
1145 assert!(rendered.contains("custom_fields:"));
1146 assert!(rendered.contains("Sprint Label: Q1"));
1147 assert!(rendered.contains("Main"));
1148 assert!(rendered.contains("<!-- field: Acceptance Criteria"));
1149 assert!(rendered.contains("AC body"));
1150 }
1151
1152 #[test]
1155 fn split_custom_sections_no_sections_returns_body_unchanged() {
1156 let (body, sections) = split_custom_sections("Hello world\n\nMore text\n");
1157 assert_eq!(body, "Hello world\n\nMore text\n");
1158 assert!(sections.is_empty());
1159 }
1160
1161 #[test]
1162 fn split_custom_sections_extracts_single_section() {
1163 let input = "Main body\n\n---\n<!-- field: Acceptance Criteria (customfield_19300) -->\n\n- Item 1\n- Item 2\n";
1164 let (body, sections) = split_custom_sections(input);
1165 assert_eq!(body, "Main body");
1166 assert_eq!(sections.len(), 1);
1167 assert_eq!(sections[0].name, "Acceptance Criteria");
1168 assert_eq!(sections[0].id, "customfield_19300");
1169 assert_eq!(sections[0].body, "- Item 1\n- Item 2");
1170 }
1171
1172 #[test]
1173 fn split_custom_sections_extracts_multiple_sections() {
1174 let input = "Main\n\n---\n<!-- field: AC (customfield_1) -->\n\nAC body\n\n---\n<!-- field: Notes (customfield_2) -->\n\nNotes body\n";
1175 let (body, sections) = split_custom_sections(input);
1176 assert_eq!(body, "Main");
1177 assert_eq!(sections.len(), 2);
1178 assert_eq!(sections[0].id, "customfield_1");
1179 assert_eq!(sections[0].body, "AC body");
1180 assert_eq!(sections[1].id, "customfield_2");
1181 assert_eq!(sections[1].body, "Notes body");
1182 }
1183
1184 #[test]
1185 fn split_custom_sections_preserves_triple_dashes_inside_body() {
1186 let input =
1189 "Before\n\n---\n\nStill body\n\n---\n<!-- field: AC (customfield_1) -->\n\nSection\n";
1190 let (body, sections) = split_custom_sections(input);
1191 assert!(body.contains("Still body"));
1192 assert_eq!(sections.len(), 1);
1193 assert_eq!(sections[0].body, "Section");
1194 }
1195
1196 #[test]
1197 fn split_custom_sections_body_starting_with_marker() {
1198 let input = "---\n<!-- field: AC (customfield_1) -->\n\nSection body\n";
1199 let (body, sections) = split_custom_sections(input);
1200 assert!(body.is_empty());
1201 assert_eq!(sections.len(), 1);
1202 assert_eq!(sections[0].id, "customfield_1");
1203 assert_eq!(sections[0].body, "Section body");
1204 }
1205
1206 #[test]
1207 fn split_custom_sections_rejects_dash_sequence_without_newline() {
1208 let input = "Before\n---foo\nMore\n---\n<!-- field: AC (customfield_1) -->\n\nS\n";
1210 let (body, sections) = split_custom_sections(input);
1211 assert!(body.contains("---foo"));
1212 assert!(body.contains("More"));
1213 assert_eq!(sections.len(), 1);
1214 }
1215
1216 #[test]
1217 fn split_custom_sections_handles_crlf_line_endings() {
1218 let input = "Main\r\n\r\n---\r\n<!-- field: AC (customfield_1) -->\r\n\r\nSection\r\n";
1219 let (_body, sections) = split_custom_sections(input);
1220 assert_eq!(sections.len(), 1);
1221 assert_eq!(sections[0].name, "AC");
1222 assert_eq!(sections[0].id, "customfield_1");
1223 }
1224
1225 #[test]
1226 fn split_custom_sections_rejects_malformed_field_tag() {
1227 let input = "Before\n\n---\n<!-- not a field tag -->\n\nStill body\n";
1231 let (body, sections) = split_custom_sections(input);
1232 assert!(body.contains("<!-- not a field tag -->"));
1233 assert!(sections.is_empty());
1234 }
1235
1236 #[test]
1237 fn split_custom_sections_roundtrips_through_render() {
1238 let issue = JiraIssue {
1239 key: "TEST-1".to_string(),
1240 summary: "S".to_string(),
1241 description_adf: Some(serde_json::json!({
1242 "type": "doc", "version": 1,
1243 "content": [{"type":"paragraph","content":[{"type":"text","text":"Main"}]}]
1244 })),
1245 status: None,
1246 issue_type: None,
1247 assignee: None,
1248 priority: None,
1249 labels: vec![],
1250 custom_fields: vec![JiraCustomField {
1251 id: "customfield_19300".to_string(),
1252 name: "Acceptance Criteria".to_string(),
1253 value: serde_json::json!({
1254 "type": "doc", "version": 1,
1255 "content": [{"type":"paragraph","content":[{"type":"text","text":"AC line"}]}]
1256 }),
1257 }],
1258 };
1259 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1260 let rendered = doc.render().unwrap();
1261 let reparsed = JfmDocument::parse(&rendered).unwrap();
1262 let (body, sections) = reparsed.split_custom_sections();
1263 assert!(body.contains("Main"));
1264 assert_eq!(sections.len(), 1);
1265 assert_eq!(sections[0].id, "customfield_19300");
1266 assert_eq!(sections[0].name, "Acceptance Criteria");
1267 assert!(sections[0].body.contains("AC line"));
1268 }
1269
1270 #[test]
1271 fn issue_with_null_custom_field_is_omitted() {
1272 let issue = JiraIssue {
1273 key: "ACCS-1".to_string(),
1274 summary: "S".to_string(),
1275 description_adf: None,
1276 status: None,
1277 issue_type: None,
1278 assignee: None,
1279 priority: None,
1280 labels: vec![],
1281 custom_fields: vec![JiraCustomField {
1282 id: "customfield_99".to_string(),
1283 name: "Empty Field".to_string(),
1284 value: serde_json::Value::Null,
1285 }],
1286 };
1287 let doc = issue_to_jfm_document(&issue, "https://org.atlassian.net").unwrap();
1288 let rendered = doc.render().unwrap();
1289 assert!(!rendered.contains("custom_fields:"));
1290 assert!(!rendered.contains("Empty Field"));
1291 }
1292
1293 #[test]
1296 fn parse_confluence_document() {
1297 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";
1298 let doc = JfmDocument::parse(input).unwrap();
1299 assert_eq!(doc.frontmatter.doc_type(), "confluence");
1300 assert_eq!(doc.frontmatter.id(), "12345");
1301 assert_eq!(doc.frontmatter.title(), "Architecture Overview");
1302 match &doc.frontmatter {
1303 JfmFrontmatter::Confluence(fm) => {
1304 assert_eq!(fm.space_key, "ENG");
1305 assert_eq!(fm.status.as_deref(), Some("current"));
1306 assert_eq!(fm.version, Some(7));
1307 }
1308 _ => panic!("Expected Confluence frontmatter"),
1309 }
1310 }
1311
1312 #[test]
1313 fn render_confluence_document() {
1314 let doc = JfmDocument {
1315 frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
1316 instance: "https://org.atlassian.net".to_string(),
1317 page_id: "12345".to_string(),
1318 title: "Architecture Overview".to_string(),
1319 space_key: "ENG".to_string(),
1320 status: Some("current".to_string()),
1321 version: Some(7),
1322 parent_id: None,
1323 }),
1324 body: "Page body here.\n".to_string(),
1325 };
1326
1327 let output = doc.render().unwrap();
1328 assert!(output.starts_with("---\n"));
1329 assert!(output.contains("type: confluence"));
1330 assert!(output.contains("page_id:"));
1331 assert!(output.contains("space_key: ENG"));
1332 assert!(output.contains("Page body here."));
1333 }
1334
1335 #[test]
1336 fn confluence_round_trip() {
1337 let doc = JfmDocument {
1338 frontmatter: JfmFrontmatter::Confluence(ConfluenceFrontmatter {
1339 instance: "https://org.atlassian.net".to_string(),
1340 page_id: "99999".to_string(),
1341 title: "Round trip".to_string(),
1342 space_key: "DEV".to_string(),
1343 status: None,
1344 version: Some(3),
1345 parent_id: Some("88888".to_string()),
1346 }),
1347 body: "Content.\n".to_string(),
1348 };
1349
1350 let rendered = doc.render().unwrap();
1351 let restored = JfmDocument::parse(&rendered).unwrap();
1352 assert_eq!(restored.frontmatter.id(), "99999");
1353 assert_eq!(restored.frontmatter.title(), "Round trip");
1354 match &restored.frontmatter {
1355 JfmFrontmatter::Confluence(fm) => {
1356 assert_eq!(fm.space_key, "DEV");
1357 assert_eq!(fm.version, Some(3));
1358 assert_eq!(fm.parent_id.as_deref(), Some("88888"));
1359 }
1360 _ => panic!("Expected Confluence frontmatter"),
1361 }
1362 }
1363
1364 #[test]
1367 fn content_item_jira_to_document() {
1368 let item = ContentItem {
1369 id: "PROJ-42".to_string(),
1370 title: "A JIRA issue".to_string(),
1371 body_adf: Some(serde_json::json!({
1372 "version": 1,
1373 "type": "doc",
1374 "content": [{
1375 "type": "paragraph",
1376 "content": [{"type": "text", "text": "Content"}]
1377 }]
1378 })),
1379 metadata: ContentMetadata::Jira {
1380 status: Some("Open".to_string()),
1381 issue_type: Some("Bug".to_string()),
1382 assignee: None,
1383 priority: None,
1384 labels: vec![],
1385 },
1386 };
1387 let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
1388 assert_eq!(doc.frontmatter.doc_type(), "jira");
1389 assert_eq!(doc.frontmatter.id(), "PROJ-42");
1390 assert!(doc.body.contains("Content"));
1391 }
1392
1393 #[test]
1394 fn content_item_confluence_to_document() {
1395 let item = ContentItem {
1396 id: "12345".to_string(),
1397 title: "A Confluence page".to_string(),
1398 body_adf: None,
1399 metadata: ContentMetadata::Confluence {
1400 space_key: "ENG".to_string(),
1401 status: Some("current".to_string()),
1402 version: Some(5),
1403 parent_id: None,
1404 },
1405 };
1406 let doc = content_item_to_document(&item, "https://org.atlassian.net").unwrap();
1407 assert_eq!(doc.frontmatter.doc_type(), "confluence");
1408 assert_eq!(doc.frontmatter.id(), "12345");
1409 assert_eq!(doc.frontmatter.title(), "A Confluence page");
1410 }
1411}