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