Skip to main content

omni_dev/atlassian/
document.rs

1//! JFM document format: YAML frontmatter + markdown body.
2//!
3//! Parses and renders documents in the format:
4//! ```text
5//! ---
6//! type: jira
7//! key: PROJ-123
8//! summary: Issue title
9//! ---
10//!
11//! Markdown body content here.
12//! ```
13
14use 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::convert::adf_to_markdown;
23use crate::atlassian::error::AtlassianError;
24use crate::atlassian::jira_types::{JiraCustomField, JiraIssue};
25
26/// A JFM document consisting of YAML frontmatter and a markdown body.
27#[derive(Debug, Clone)]
28pub struct JfmDocument {
29    /// Parsed frontmatter metadata fields.
30    pub frontmatter: JfmFrontmatter,
31
32    /// Raw markdown body (not parsed — passed through to/from ADF conversion).
33    pub body: String,
34}
35
36/// YAML frontmatter for a JFM document.
37///
38/// Dispatched by the `type` field to the appropriate backend-specific struct.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type")]
41pub enum JfmFrontmatter {
42    /// JIRA issue frontmatter.
43    #[serde(rename = "jira")]
44    Jira(JiraFrontmatter),
45
46    /// Confluence page frontmatter.
47    #[serde(rename = "confluence")]
48    Confluence(ConfluenceFrontmatter),
49}
50
51/// JIRA-specific frontmatter fields.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct JiraFrontmatter {
54    /// Atlassian instance base URL.
55    pub instance: String,
56
57    /// JIRA issue key (e.g., "PROJ-123"). Empty when creating a new issue.
58    #[serde(default)]
59    pub key: String,
60
61    /// Project key (e.g., "PROJ"). Used when creating issues without an existing key.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub project: Option<String>,
64
65    /// Issue summary (title).
66    pub summary: String,
67
68    /// Issue status name.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub status: Option<String>,
71
72    /// Issue type name (Bug, Story, Task, etc.).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub issue_type: Option<String>,
75
76    /// Assignee display name.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub assignee: Option<String>,
79
80    /// Priority name.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub priority: Option<String>,
83
84    /// Labels applied to the issue.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub labels: Vec<String>,
87
88    /// Scalar custom field values keyed by human-readable field name.
89    ///
90    /// Populated when the issue was fetched with `--fields` or
91    /// `--all-fields`. Rich-text (ADF) custom fields are rendered into the
92    /// document body as extra sections instead.
93    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94    pub custom_fields: BTreeMap<String, serde_yaml::Value>,
95}
96
97/// Lenient JIRA frontmatter used **only** by the `jira create` path.
98///
99/// Unlike [`JiraFrontmatter`] — which backs the strict read/write/edit
100/// round-trip and requires `instance` and `summary` — every field here is
101/// optional, so the create command can fall back to CLI flags for anything
102/// the document omits (or accept a document with no frontmatter at all).
103/// Unknown fields carried over from a round-tripped issue (`instance`,
104/// `status`, `assignee`, …) are ignored: `instance` is irrelevant to create
105/// because the client is built from auth config, not the document.
106#[derive(Debug, Clone, Default, Deserialize)]
107#[serde(default)]
108pub struct JiraCreateFrontmatter {
109    /// Document type tag, when present. Used to reject `confluence` documents.
110    pub r#type: Option<String>,
111
112    /// JIRA issue key (e.g., "PROJ-123"). The project is derived from it when
113    /// no explicit project is given.
114    pub key: String,
115
116    /// Project key (e.g., "PROJ").
117    pub project: Option<String>,
118
119    /// Issue summary (title).
120    pub summary: Option<String>,
121
122    /// Issue type name (Bug, Story, Task, etc.).
123    pub issue_type: Option<String>,
124
125    /// Labels applied to the issue.
126    pub labels: Vec<String>,
127
128    /// Scalar custom field values keyed by human-readable field name.
129    pub custom_fields: BTreeMap<String, serde_yaml::Value>,
130}
131
132/// Confluence-specific frontmatter fields.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ConfluenceFrontmatter {
135    /// Atlassian instance base URL.
136    pub instance: String,
137
138    /// Confluence page ID. Empty when creating a new page.
139    #[serde(default)]
140    pub page_id: String,
141
142    /// Page title.
143    pub title: String,
144
145    /// Space key (e.g., "ENG").
146    pub space_key: String,
147
148    /// Page status ("current" or "draft").
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub status: Option<String>,
151
152    /// Page version number.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub version: Option<u32>,
155
156    /// Parent page ID.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub parent_id: Option<String>,
159}
160
161impl JfmFrontmatter {
162    /// Returns the Atlassian instance URL.
163    pub fn instance(&self) -> &str {
164        match self {
165            Self::Jira(fm) => &fm.instance,
166            Self::Confluence(fm) => &fm.instance,
167        }
168    }
169
170    /// Returns the content identifier (JIRA key or Confluence page ID).
171    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    /// Returns the content title (JIRA summary or Confluence page title).
179    pub fn title(&self) -> &str {
180        match self {
181            Self::Jira(fm) => &fm.summary,
182            Self::Confluence(fm) => &fm.title,
183        }
184    }
185
186    /// Returns the document type name.
187    pub fn doc_type(&self) -> &str {
188        match self {
189            Self::Jira(_) => "jira",
190            Self::Confluence(_) => "confluence",
191        }
192    }
193
194    /// Returns the JIRA custom field scalar map, or `None` when the
195    /// frontmatter is not a JIRA variant.
196    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
204/// Validates that a string looks like a JIRA issue key (e.g., "PROJ-123").
205pub 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
214/// Converts a [`JiraIssue`] into a [`JfmDocument`] with YAML frontmatter.
215///
216/// Custom fields present on the issue are partitioned: rich-text (ADF)
217/// fields become additional JFM sections appended to the body (prefixed
218/// with an HTML-comment tag so the `write` round-trip can identify them),
219/// while scalar fields are serialized into the frontmatter's
220/// `custom_fields` map keyed by human-readable name.
221pub 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
252/// Renders a single custom field into either the body (rich text) or the
253/// frontmatter scalar map.
254fn 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, &section_md);
264    } else if let Some(scalar) = extract_custom_field_scalar(&field.value) {
265        scalars.insert(field.name.clone(), scalar);
266    }
267    // Otherwise the field is null or an unrecognized shape — omit it.
268    Ok(())
269}
270
271/// Appends a rich-text custom field to the body as a tagged section.
272fn 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/// A single rich-text custom field section parsed out of a JFM body.
290///
291/// Emitted by [`issue_to_jfm_document`] via `append_custom_section` and
292/// recovered by [`JfmDocument::split_custom_sections`] so the write path
293/// can round-trip a field through `markdown_to_adf` for upload.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct CustomFieldSection {
296    /// Human-readable field name captured from the `<!-- field: Name (id) -->` tag.
297    pub name: String,
298
299    /// Stable field ID captured from the tag (e.g., `customfield_19300`).
300    pub id: String,
301
302    /// Markdown body of the section (no trailing newline guarantees).
303    pub body: String,
304}
305
306/// Splits a JFM body into the primary body and any trailing custom-field
307/// sections.
308///
309/// Recognizes the separator emitted by [`append_custom_section`]: a line
310/// containing only `---` followed by a line matching
311/// `<!-- field: <name> (<id>) -->`. Everything before the first such
312/// separator is the primary body; each subsequent separator starts a new
313/// section whose content runs up to the next separator (or end of input).
314pub(crate) fn split_custom_sections(body: &str) -> (String, Vec<CustomFieldSection>) {
315    // (marker_start, content_start, name, id)
316    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        // Require exactly "---" on its own line, followed by "\n" (or "\r\n").
325        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
366/// Finds the next line-anchored `---` at or after `from`. A marker is
367/// either at the very start of `body` or preceded by a newline.
368fn 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
378/// Parses an HTML-comment field tag at `start` and returns
379/// `(name, id, content_start)` where `content_start` is the byte offset
380/// immediately after the comment line's newline.
381fn 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
405/// Returns `true` if `value` has the shape of an Atlassian Document Format
406/// document (`{"type":"doc","version":_,"content":[...]}`).
407fn 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
416/// Converts a custom field's raw JSON value into a scalar YAML
417/// representation suitable for frontmatter serialization.
418///
419/// - Option/select objects (`{self, value, id}`) collapse to their
420///   `value` string.
421/// - User-picker objects collapse to their `displayName`.
422/// - Arrays recurse per element, dropping null/unknown entries.
423/// - Primitives (bool/number/string) pass through unchanged.
424/// - Unknown objects pass through as a structured YAML mapping.
425/// - Null returns `None`.
426fn 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
458/// Converts a [`ContentItem`] into a [`JfmDocument`] with YAML frontmatter.
459///
460/// Dispatches on the [`ContentMetadata`] variant to populate the correct
461/// frontmatter fields for JIRA or Confluence content.
462pub 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
509/// Splits a JFM document into its raw frontmatter YAML (if present) and its
510/// markdown body.
511///
512/// Returns `(None, whole_input)` when `input` has no opening `---` delimiter —
513/// the entire input is the body. Returns `(Some(yaml), body)` when a
514/// frontmatter block is present; a `---` opener with no matching closing
515/// delimiter is an error.
516///
517/// This is the mechanical delimiter split only — it performs no typed
518/// deserialization, so callers choose how strictly to interpret the
519/// frontmatter: the strict round-trip via [`JfmDocument::parse`], or the
520/// lenient flag-fallback on the `jira create` path.
521pub(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    // Find the closing '---' delimiter (skip the opening one)
529    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..]; // skip "\n---"
538
539    // Strip the first newline after the closing delimiter
540    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    /// Parses a JFM document from a string.
550    ///
551    /// Expects the format: `---\n<yaml frontmatter>\n---\n<markdown body>`
552    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    /// Renders the document back to a string with YAML frontmatter and markdown body.
567    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            // Ensure trailing newline
579            if !self.body.ends_with('\n') {
580                output.push('\n');
581            }
582        }
583
584        Ok(output)
585    }
586
587    /// Splits the body into the primary markdown and any trailing custom-field
588    /// sections emitted by the custom-field-aware read path.
589    ///
590    /// Non-destructive — returns owned strings and leaves `self.body`
591    /// unchanged so `render()` continues to produce a lossless round-trip.
592    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    // ── split_frontmatter ──────────────────────────────────────────
661
662    #[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    // ── JiraCreateFrontmatter ──────────────────────────────────────
686
687    #[test]
688    fn jira_create_frontmatter_tolerates_round_tripped_fields() {
689        // A full round-tripped issue (instance/status/assignee present) must
690        // parse — the create-irrelevant fields are simply ignored.
691        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    // ── validate_issue_key tests ────────��────────────────────────────
767
768    #[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    // ── issue_to_jfm_document tests ─────────��─────────────────────────
785
786    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    // ── Custom field helpers ────────────────────────────────────────
890
891    // ── append_custom_section ───────────────────────────────────────
892
893    #[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    // ── split_custom_sections ──────────────────────────────────────
1153
1154    #[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        // A `---` without the follow-up comment tag is just content, not a
1187        // section separator.
1188        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        // `---foo` on a line is not a valid marker.
1209        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        // The `---` is present and line-anchored, but the next line is not a
1228        // proper `<!-- field: Name (id) -->` tag, so the section is treated
1229        // as plain body content.
1230        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    // ── Confluence frontmatter tests ───���─────────────────────────────
1294
1295    #[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    // ── content_item_to_document tests ───────────────────────────────
1365
1366    #[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}