Skip to main content

omni_dev/atlassian/
custom_fields.rs

1//! Resolves frontmatter custom field values and body sections to a JIRA
2//! `fields` payload for write operations.
3//!
4//! Input:
5//! - Frontmatter scalar map keyed by human name (from `custom_fields:` in JFM).
6//! - Body sections parsed via `crate::atlassian::document::split_custom_sections`.
7//! - [`EditMeta`] fetched for the target issue (or create target).
8//!
9//! Output: `{ field_id -> api_json }` ready to be merged into a PUT/POST.
10
11use std::collections::BTreeMap;
12
13use anyhow::{anyhow, bail, Context, Result};
14
15use crate::atlassian::adf_validated::ValidatedAdfDocument;
16use crate::atlassian::client::{EditMeta, EditMetaField};
17use crate::atlassian::convert::markdown_to_adf;
18use crate::atlassian::document::CustomFieldSection;
19
20#[cfg(test)]
21use crate::atlassian::client::TEXTAREA_CUSTOM_TYPE as CUSTOM_TEXTAREA;
22
23/// Resolves a mixed set of frontmatter scalars and body sections into an
24/// API-ready custom field map keyed by stable field ID.
25///
26/// - **Scalars** are dispatched by schema: option/radiobutton fields become
27///   `{"value": "..."}`, textfield/number/date pass through, rich-text
28///   fields are rejected (must use a body section instead).
29/// - **Sections** must reference rich-text fields; their markdown is
30///   converted to ADF via [`markdown_to_adf`].
31///
32/// Field names are looked up in [`EditMeta`]; entries already formatted as
33/// `customfield_<digits>` bypass the lookup. An unknown or ambiguous name
34/// produces an error naming the available editable fields.
35pub fn resolve_custom_fields(
36    scalars: &BTreeMap<String, serde_yaml::Value>,
37    sections: &[CustomFieldSection],
38    editmeta: &EditMeta,
39) -> Result<BTreeMap<String, serde_json::Value>> {
40    let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
41
42    for (key, value) in scalars {
43        let (id, field) = lookup_field(editmeta, key)?;
44        if field.is_adf_rich_text() {
45            let payload = rich_text_scalar_to_api_value(value, field, &id)?;
46            out.insert(id, payload);
47            continue;
48        }
49        let payload = scalar_to_api_value(value, field).with_context(|| {
50            format!(
51                "Failed to convert custom field '{}' ({}) to API value",
52                field.name, id
53            )
54        })?;
55        out.insert(id, payload);
56    }
57
58    for section in sections {
59        let (id, field) = resolve_section_field(editmeta, section)?;
60        if !field.is_adf_rich_text() {
61            bail!(
62                "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
63                field.name, id
64            );
65        }
66        let adf = markdown_to_adf(&section.body).with_context(|| {
67            format!(
68                "Failed to convert body for custom field '{}' ({}) to ADF",
69                field.name, id
70            )
71        })?;
72        let validated = ValidatedAdfDocument::try_new(adf).with_context(|| {
73            format!(
74                "Custom field '{}' ({}) failed ADF nesting validation",
75                field.name, id
76            )
77        })?;
78        let value = serde_json::to_value(&validated)
79            .context("Failed to serialize custom field ADF document")?;
80        out.insert(id, value);
81    }
82
83    Ok(out)
84}
85
86/// Looks up a field by id-or-name, preferring exact `customfield_<id>`
87/// matches before falling back to a name lookup.
88fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
89    if looks_like_field_id(key) {
90        if let Some(field) = editmeta.fields.get(key) {
91            return Ok((key.to_string(), field));
92        }
93        // Fall through to name lookup in case the caller named a field
94        // literally "customfield_something".
95    }
96
97    let matches: Vec<_> = editmeta
98        .fields
99        .iter()
100        .filter(|(_, f)| f.name == key)
101        .collect();
102
103    match matches.as_slice() {
104        [] => {
105            let candidates = editmeta
106                .fields
107                .iter()
108                .map(|(id, f)| format!("  {id}  {}", f.name))
109                .collect::<Vec<_>>()
110                .join("\n");
111            Err(anyhow!(
112                "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
113            ))
114        }
115        [(id, field)] => Ok(((*id).clone(), field)),
116        multi => {
117            let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
118            Err(anyhow!(
119                "Ambiguous custom field '{key}' matches multiple IDs: {}",
120                ids.join(", ")
121            ))
122        }
123    }
124}
125
126/// Resolves a body section's tag (which carries both name and id) against
127/// editmeta, trusting the id when both are present.
128fn resolve_section_field<'a>(
129    editmeta: &'a EditMeta,
130    section: &CustomFieldSection,
131) -> Result<(String, &'a EditMetaField)> {
132    if let Some(field) = editmeta.fields.get(&section.id) {
133        return Ok((section.id.clone(), field));
134    }
135    lookup_field(editmeta, &section.name)
136}
137
138fn looks_like_field_id(s: &str) -> bool {
139    s.starts_with("customfield_") && s[12..].chars().all(|c| c.is_ascii_digit())
140}
141
142/// Converts a frontmatter / `--set-field` scalar targeting a rich-text custom
143/// field into the API JSON shape.
144///
145/// String values are treated as JFM markdown and converted to ADF (matching
146/// the contract for `content`/description and for body sections). An empty
147/// string or YAML null clears the field by emitting `null`. Non-string
148/// scalars (numbers, bools, sequences, mappings) are rejected — rich-text
149/// fields require either a JFM string or a body section.
150///
151/// Null handling is load-bearing for the CLI: `--set-field "Name="` parses
152/// the empty RHS as YAML null (not a string), so a "clear the field"
153/// invocation arrives here as `Value::Null`.
154fn rich_text_scalar_to_api_value(
155    value: &serde_yaml::Value,
156    field: &EditMetaField,
157    id: &str,
158) -> Result<serde_json::Value> {
159    let s = match value {
160        serde_yaml::Value::String(s) => s.clone(),
161        serde_yaml::Value::Null => String::new(),
162        _ => bail!(
163            "Field '{}' ({}) is a rich-text field; supply JFM markdown as a string or use a `<!-- field: {} ({}) -->` body section",
164            field.name,
165            id,
166            field.name,
167            id
168        ),
169    };
170    string_to_rich_text_api_value(&s, &field.name, id)
171}
172
173/// Shared conversion: empty → `null`; otherwise JFM → validated ADF JSON.
174fn string_to_rich_text_api_value(s: &str, field_name: &str, id: &str) -> Result<serde_json::Value> {
175    if s.is_empty() {
176        return Ok(serde_json::Value::Null);
177    }
178    let adf = markdown_to_adf(s)?;
179    let validated = ValidatedAdfDocument::try_new(adf).with_context(|| {
180        format!("Custom field '{field_name}' ({id}) failed ADF nesting validation")
181    })?;
182    serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
183}
184
185/// Applies JFM → ADF conversion in-place to string values targeting rich-text
186/// custom fields, per issue #866.
187///
188/// For each entry in `fields`:
189/// - If the key is not present in `editmeta.fields`, leave the value
190///   untouched (pass-through — the API will surface its own error).
191/// - If the resolved field is not a rich-text textarea, leave the value
192///   untouched.
193/// - If the value is a JSON object, leave it untouched (assumed to be a raw
194///   ADF document — backwards-compatible).
195/// - If the value is a JSON string, treat it as JFM markdown and convert.
196///   An empty string becomes `null`, which clears the field.
197/// - Any other value type (number/bool/array/null) is left untouched.
198///
199/// Designed for the MCP `jira_write` `fields` escape hatch: lets callers pass
200/// `"customfield_19300": "- bullet\n- bullet"` and get the right ADF on the
201/// wire without hand-crafting the document.
202pub fn convert_textarea_string_values(
203    fields: &mut BTreeMap<String, serde_json::Value>,
204    editmeta: &EditMeta,
205) -> Result<()> {
206    for (id, value) in fields.iter_mut() {
207        let Some(field) = editmeta.fields.get(id) else {
208            continue;
209        };
210        if !field.is_adf_rich_text() {
211            continue;
212        }
213        let serde_json::Value::String(s) = value else {
214            continue;
215        };
216        *value = string_to_rich_text_api_value(s, &field.name, id)?;
217    }
218    Ok(())
219}
220
221/// Dispatches a scalar YAML value to the API shape expected for a given
222/// field schema.
223fn scalar_to_api_value(
224    value: &serde_yaml::Value,
225    field: &EditMetaField,
226) -> Result<serde_json::Value> {
227    let kind = field.schema.kind.as_str();
228    let custom = field.schema.custom.as_deref();
229    match (kind, custom) {
230        ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
231            let s = yaml_as_string(value).with_context(|| {
232                format!("expected a string for option field '{}'", field.name)
233            })?;
234            Ok(serde_json::json!({ "value": s }))
235        }
236        ("array", _) => {
237            let seq = value.as_sequence().ok_or_else(|| {
238                anyhow!("expected a sequence for array field '{}'", field.name)
239            })?;
240            let items: Vec<serde_json::Value> = seq
241                .iter()
242                .map(|v| {
243                    let s = yaml_as_string(v).with_context(|| {
244                        format!(
245                            "expected a string array element for field '{}'",
246                            field.name
247                        )
248                    })?;
249                    Ok(serde_json::json!({ "value": s }))
250                })
251                .collect::<Result<_>>()?;
252            Ok(serde_json::Value::Array(items))
253        }
254        ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
255        (other, _) => Err(anyhow!(
256            "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
257            field.name
258        )),
259    }
260}
261
262fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
263    match value {
264        serde_yaml::Value::String(s) => Ok(s.clone()),
265        serde_yaml::Value::Bool(b) => Ok(b.to_string()),
266        serde_yaml::Value::Number(n) => Ok(n.to_string()),
267        _ => Err(anyhow!("expected a scalar string value")),
268    }
269}
270
271fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
272    let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
273    serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
274        .context("Failed to convert YAML value to JSON")
275}
276
277/// Parses a `--set-field NAME=VALUE` argument into a `(name, value)` pair.
278///
279/// The value is parsed as YAML when possible so `--set-field "Points=8"`
280/// becomes a number and `--set-field "Enabled=true"` becomes a bool.
281/// Values that fail to parse as YAML fall back to plain strings.
282pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
283    let (name, value) = input
284        .split_once('=')
285        .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
286    let name = name.trim().to_string();
287    if name.is_empty() {
288        bail!("--set-field requires a non-empty name before '='");
289    }
290    let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
291        .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
292    Ok((name, yaml_value))
293}
294
295/// Translates an `accountId`-style assignee/reporter input to the JSON
296/// shape JIRA expects.
297///
298/// The empty string clears the user (Atlassian's supported `null` payload);
299/// any other value is wrapped as `{"accountId": "<value>"}`. The literal
300/// `-1` is preserved as `{"accountId": "-1"}`, which JIRA interprets as
301/// automatic assignment.
302pub fn user_field_value(raw: &str) -> serde_json::Value {
303    if raw.is_empty() {
304        serde_json::Value::Null
305    } else {
306        serde_json::json!({ "accountId": raw })
307    }
308}
309
310/// Merges typed `assignee`/`reporter` parameters into a resolved JIRA fields
311/// map.
312///
313/// Rejects collisions where the same field id has already been set
314/// (typically via the `fields` escape hatch on the MCP side or `--set-field`
315/// on the CLI side). `other_source_label` is interpolated into the error
316/// message to identify the colliding source — for example
317/// `the same key inside fields` or ``--set-field`` of the same name``.
318pub fn apply_user_field_overrides(
319    fields: &mut BTreeMap<String, serde_json::Value>,
320    assignee: Option<&str>,
321    reporter: Option<&str>,
322    other_source_label: &str,
323) -> Result<()> {
324    if let Some(value) = assignee {
325        if fields.contains_key("assignee") {
326            bail!("`assignee` collides with {other_source_label}; supply only one");
327        }
328        fields.insert("assignee".to_string(), user_field_value(value));
329    }
330    if let Some(value) = reporter {
331        if fields.contains_key("reporter") {
332            bail!("`reporter` collides with {other_source_label}; supply only one");
333        }
334        fields.insert("reporter".to_string(), user_field_value(value));
335    }
336    Ok(())
337}
338
339/// Merges CLI `--set-field` overrides into a frontmatter scalar map,
340/// with CLI overriding frontmatter on name conflicts.
341pub fn merge_set_field_overrides(
342    frontmatter: BTreeMap<String, serde_yaml::Value>,
343    overrides: Vec<(String, serde_yaml::Value)>,
344) -> BTreeMap<String, serde_yaml::Value> {
345    let mut merged = frontmatter;
346    for (name, value) in overrides {
347        merged.insert(name, value);
348    }
349    merged
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355    use super::*;
356    use crate::atlassian::client::{EditMetaField, EditMetaSchema};
357
358    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
359        let mut fields = BTreeMap::new();
360        for (id, name, kind, custom) in entries {
361            fields.insert(
362                (*id).to_string(),
363                EditMetaField {
364                    name: (*name).to_string(),
365                    schema: EditMetaSchema {
366                        kind: (*kind).to_string(),
367                        custom: custom.map(str::to_string),
368                    },
369                },
370            );
371        }
372        EditMeta { fields }
373    }
374
375    // ── user_field_value ──────────────────────────────────────
376
377    #[test]
378    fn user_field_value_empty_string_is_null() {
379        assert_eq!(user_field_value(""), serde_json::Value::Null);
380    }
381
382    #[test]
383    fn user_field_value_account_id_wrapped() {
384        assert_eq!(
385            user_field_value("abc123"),
386            serde_json::json!({"accountId": "abc123"})
387        );
388    }
389
390    #[test]
391    fn user_field_value_dash_one_preserves_auto_assign() {
392        assert_eq!(
393            user_field_value("-1"),
394            serde_json::json!({"accountId": "-1"})
395        );
396    }
397
398    // ── apply_user_field_overrides ────────────────────────────
399
400    #[test]
401    fn apply_user_field_overrides_inserts_assignee_and_reporter() {
402        let mut fields = BTreeMap::new();
403        apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
404        assert_eq!(
405            fields.get("assignee"),
406            Some(&serde_json::json!({"accountId": "a1"}))
407        );
408        assert_eq!(
409            fields.get("reporter"),
410            Some(&serde_json::json!({"accountId": "r1"}))
411        );
412    }
413
414    #[test]
415    fn apply_user_field_overrides_skips_none() {
416        let mut fields = BTreeMap::new();
417        apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
418        assert!(fields.is_empty());
419    }
420
421    #[test]
422    fn apply_user_field_overrides_empty_string_clears() {
423        let mut fields = BTreeMap::new();
424        apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
425        assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
426    }
427
428    #[test]
429    fn apply_user_field_overrides_assignee_collision_errors() {
430        let mut fields = BTreeMap::new();
431        fields.insert(
432            "assignee".to_string(),
433            serde_json::json!({"accountId": "existing"}),
434        );
435        let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
436            .unwrap_err();
437        let msg = err.to_string();
438        assert!(msg.contains("assignee"));
439        assert!(msg.contains("the test source"));
440    }
441
442    #[test]
443    fn apply_user_field_overrides_reporter_collision_errors() {
444        let mut fields = BTreeMap::new();
445        fields.insert(
446            "reporter".to_string(),
447            serde_json::json!({"accountId": "existing"}),
448        );
449        let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
450            .unwrap_err();
451        assert!(err.to_string().contains("reporter"));
452    }
453
454    #[test]
455    fn scalar_option_field_wraps_in_value_object() {
456        let editmeta = meta(&[(
457            "customfield_10001",
458            "Planned / Unplanned Work",
459            "option",
460            Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
461        )]);
462        let mut scalars = BTreeMap::new();
463        scalars.insert(
464            "Planned / Unplanned Work".to_string(),
465            serde_yaml::Value::String("Unplanned".to_string()),
466        );
467        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
468        assert_eq!(
469            out.get("customfield_10001").unwrap(),
470            &serde_json::json!({ "value": "Unplanned" })
471        );
472    }
473
474    #[test]
475    fn scalar_radiobutton_wraps_in_value_object() {
476        let editmeta = meta(&[(
477            "customfield_10002",
478            "Risk",
479            "string",
480            Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
481        )]);
482        let mut scalars = BTreeMap::new();
483        scalars.insert(
484            "Risk".to_string(),
485            serde_yaml::Value::String("High".to_string()),
486        );
487        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
488        assert_eq!(
489            out.get("customfield_10002").unwrap(),
490            &serde_json::json!({ "value": "High" })
491        );
492    }
493
494    #[test]
495    fn scalar_number_field_passes_through() {
496        let editmeta = meta(&[(
497            "customfield_10003",
498            "Story points",
499            "number",
500            Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
501        )]);
502        let mut scalars = BTreeMap::new();
503        scalars.insert(
504            "Story points".to_string(),
505            serde_yaml::Value::Number(8.into()),
506        );
507        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
508        assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
509    }
510
511    #[test]
512    fn scalar_array_option_field_wraps_each_item() {
513        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
514        let mut scalars = BTreeMap::new();
515        scalars.insert(
516            "Components".to_string(),
517            serde_yaml::Value::Sequence(vec![
518                serde_yaml::Value::String("backend".to_string()),
519                serde_yaml::Value::String("auth".to_string()),
520            ]),
521        );
522        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
523        assert_eq!(
524            out.get("customfield_10004").unwrap(),
525            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
526        );
527    }
528
529    #[test]
530    fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
531        // Issue #866: a string scalar targeting a textarea custom field is
532        // treated as JFM markdown and converted to ADF.
533        let editmeta = meta(&[(
534            "customfield_19300",
535            "Acceptance Criteria",
536            "string",
537            Some(CUSTOM_TEXTAREA),
538        )]);
539        let mut scalars = BTreeMap::new();
540        scalars.insert(
541            "Acceptance Criteria".to_string(),
542            serde_yaml::Value::String("- one\n- two".to_string()),
543        );
544        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
545        let value = out.get("customfield_19300").unwrap();
546        assert_eq!(value["type"], "doc");
547        assert_eq!(value["version"], 1);
548        assert!(value["content"].is_array());
549    }
550
551    #[test]
552    fn scalar_empty_string_to_rich_text_field_clears() {
553        let editmeta = meta(&[(
554            "customfield_19300",
555            "Acceptance Criteria",
556            "string",
557            Some(CUSTOM_TEXTAREA),
558        )]);
559        let mut scalars = BTreeMap::new();
560        scalars.insert(
561            "Acceptance Criteria".to_string(),
562            serde_yaml::Value::String(String::new()),
563        );
564        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
565        assert_eq!(
566            out.get("customfield_19300").unwrap(),
567            &serde_json::Value::Null
568        );
569    }
570
571    #[test]
572    fn scalar_yaml_null_to_rich_text_field_clears() {
573        // Distinct from the empty-string case: the CLI's `--set-field Name=`
574        // parses the empty RHS as YAML null (not a string), so this arm
575        // covers the production code path callers actually traverse to
576        // clear a rich-text field from the command line.
577        let editmeta = meta(&[(
578            "customfield_19300",
579            "Acceptance Criteria",
580            "string",
581            Some(CUSTOM_TEXTAREA),
582        )]);
583        let mut scalars = BTreeMap::new();
584        scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
585        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
586        assert_eq!(
587            out.get("customfield_19300").unwrap(),
588            &serde_json::Value::Null
589        );
590    }
591
592    #[test]
593    fn scalar_non_string_to_rich_text_field_errors() {
594        // Non-string scalars (numbers, bools, mappings, sequences) targeting
595        // a rich-text field still need a body section / JFM string.
596        let editmeta = meta(&[(
597            "customfield_19300",
598            "Acceptance Criteria",
599            "string",
600            Some(CUSTOM_TEXTAREA),
601        )]);
602        let mut scalars = BTreeMap::new();
603        scalars.insert(
604            "Acceptance Criteria".to_string(),
605            serde_yaml::Value::Number(42.into()),
606        );
607        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
608        let msg = err.to_string();
609        assert!(msg.contains("rich-text field"), "got: {msg}");
610        assert!(msg.contains("JFM markdown"), "got: {msg}");
611    }
612
613    #[test]
614    fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
615        let editmeta = meta(&[(
616            "customfield_19300",
617            "Acceptance Criteria",
618            "string",
619            Some(CUSTOM_TEXTAREA),
620        )]);
621        let mut scalars = BTreeMap::new();
622        scalars.insert(
623            "Acceptance Criteria".to_string(),
624            serde_yaml::Value::String(
625                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
626            ),
627        );
628        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
629        let msg = format!("{err:#}");
630        assert!(msg.contains("Acceptance Criteria"));
631        assert!(msg.contains("ADF nesting validation"));
632        assert!(msg.contains("`expand` cannot be a child of `panel`"));
633    }
634
635    #[test]
636    fn rich_text_section_becomes_adf_payload() {
637        let editmeta = meta(&[(
638            "customfield_19300",
639            "Acceptance Criteria",
640            "string",
641            Some(CUSTOM_TEXTAREA),
642        )]);
643        let sections = [CustomFieldSection {
644            name: "Acceptance Criteria".to_string(),
645            id: "customfield_19300".to_string(),
646            body: "- Item one\n- Item two".to_string(),
647        }];
648        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
649        let value = out.get("customfield_19300").unwrap();
650        assert_eq!(value["type"], "doc");
651        assert_eq!(value["version"], 1);
652        assert!(value["content"].is_array());
653    }
654
655    #[test]
656    fn rich_text_section_with_invalid_adf_nesting_errors() {
657        // Issue #714: a section whose body produces ADF that violates
658        // Confluence's nesting constraints (here panel→expand) must be
659        // rejected with the validation context, not silently included in the
660        // payload.
661        let editmeta = meta(&[(
662            "customfield_19300",
663            "Acceptance Criteria",
664            "string",
665            Some(CUSTOM_TEXTAREA),
666        )]);
667        let sections = [CustomFieldSection {
668            name: "Acceptance Criteria".to_string(),
669            id: "customfield_19300".to_string(),
670            body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
671        }];
672        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
673        let msg = format!("{err:#}");
674        assert!(msg.contains("Acceptance Criteria"));
675        assert!(msg.contains("ADF nesting validation"));
676        assert!(msg.contains("`expand` cannot be a child of `panel`"));
677    }
678
679    #[test]
680    fn section_pointing_at_non_rich_text_field_errors() {
681        let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
682        let sections = [CustomFieldSection {
683            name: "Priority Flag".to_string(),
684            id: "customfield_10001".to_string(),
685            body: "Some text".to_string(),
686        }];
687        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
688        assert!(err.to_string().contains("not a rich-text field"));
689    }
690
691    #[test]
692    fn unknown_field_name_errors_with_suggestions() {
693        let editmeta = meta(&[
694            ("customfield_1", "Alpha", "string", None),
695            ("customfield_2", "Beta", "string", None),
696        ]);
697        let mut scalars = BTreeMap::new();
698        scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
699        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
700        let msg = err.to_string();
701        assert!(msg.contains("Unknown custom field 'Gamma'"));
702        assert!(msg.contains("Alpha"));
703        assert!(msg.contains("Beta"));
704    }
705
706    #[test]
707    fn field_id_bypasses_name_lookup() {
708        let editmeta = meta(&[(
709            "customfield_10001",
710            "Planned / Unplanned Work",
711            "option",
712            None,
713        )]);
714        let mut scalars = BTreeMap::new();
715        scalars.insert(
716            "customfield_10001".to_string(),
717            serde_yaml::Value::String("Unplanned".to_string()),
718        );
719        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
720        assert_eq!(
721            out.get("customfield_10001").unwrap(),
722            &serde_json::json!({ "value": "Unplanned" })
723        );
724    }
725
726    #[test]
727    fn ambiguous_field_name_errors_listing_ids() {
728        let editmeta = meta(&[
729            ("customfield_1", "Duplicate", "string", None),
730            ("customfield_2", "Duplicate", "string", None),
731        ]);
732        let mut scalars = BTreeMap::new();
733        scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
734        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
735        let msg = err.to_string();
736        assert!(msg.contains("Ambiguous"));
737        assert!(msg.contains("customfield_1"));
738        assert!(msg.contains("customfield_2"));
739    }
740
741    #[test]
742    fn array_field_requires_sequence_value() {
743        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
744        let mut scalars = BTreeMap::new();
745        scalars.insert(
746            "Components".to_string(),
747            serde_yaml::Value::String("not-a-sequence".to_string()),
748        );
749        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
750        assert!(format!("{err:#}").contains("expected a sequence"));
751    }
752
753    #[test]
754    fn array_element_must_be_scalar_string() {
755        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
756        let mut scalars = BTreeMap::new();
757        scalars.insert(
758            "Components".to_string(),
759            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
760                serde_yaml::Value::String("nested".to_string()),
761            ])]),
762        );
763        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
764        assert!(format!("{err:#}").contains("expected a scalar string value"));
765    }
766
767    #[test]
768    fn unsupported_schema_type_errors_with_field_name() {
769        let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
770        let mut scalars = BTreeMap::new();
771        scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
772        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
773        let msg = format!("{err:#}");
774        assert!(msg.contains("Unsupported field type 'user'"));
775        assert!(msg.contains("Reporter"));
776    }
777
778    #[test]
779    fn option_field_accepts_bool_and_number_scalars() {
780        let editmeta = meta(&[
781            (
782                "customfield_bool",
783                "Toggle",
784                "option",
785                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
786            ),
787            (
788                "customfield_num",
789                "Number choice",
790                "option",
791                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
792            ),
793        ]);
794        let mut scalars = BTreeMap::new();
795        scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
796        scalars.insert(
797            "Number choice".to_string(),
798            serde_yaml::Value::Number(3.into()),
799        );
800        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
801        assert_eq!(
802            out.get("customfield_bool").unwrap(),
803            &serde_json::json!({"value": "true"})
804        );
805        assert_eq!(
806            out.get("customfield_num").unwrap(),
807            &serde_json::json!({"value": "3"})
808        );
809    }
810
811    #[test]
812    fn option_field_rejects_non_scalar_value() {
813        let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
814        let mut mapping = serde_yaml::Mapping::new();
815        mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
816        let mut scalars = BTreeMap::new();
817        scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
818        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
819        assert!(format!("{err:#}").contains("expected a scalar string value"));
820    }
821
822    #[test]
823    fn section_with_stale_id_falls_back_to_name_lookup() {
824        // editmeta has the field under a new id; the section tag carries an
825        // older id. Resolver should fall back to name lookup and find it.
826        let editmeta = meta(&[(
827            "customfield_NEW",
828            "Acceptance Criteria",
829            "string",
830            Some(CUSTOM_TEXTAREA),
831        )]);
832        let sections = [CustomFieldSection {
833            name: "Acceptance Criteria".to_string(),
834            id: "customfield_OLD".to_string(),
835            body: "body".to_string(),
836        }];
837        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
838        assert!(out.contains_key("customfield_NEW"));
839        assert!(!out.contains_key("customfield_OLD"));
840    }
841
842    #[test]
843    fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
844        // A `customfield_<digits>` key that isn't in editmeta should still
845        // try a name lookup before erroring.
846        let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
847        let mut scalars = BTreeMap::new();
848        scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
849        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
850        assert!(err.to_string().contains("Unknown custom field"));
851    }
852
853    // ── parse_set_field / merge_set_field_overrides ─────────────────
854
855    #[test]
856    fn parse_set_field_bare_string_value() {
857        let (name, value) = parse_set_field("Status=Open").unwrap();
858        assert_eq!(name, "Status");
859        assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
860    }
861
862    #[test]
863    fn parse_set_field_numeric_value_becomes_number() {
864        let (_name, value) = parse_set_field("Points=8").unwrap();
865        assert_eq!(value, serde_yaml::Value::Number(8.into()));
866    }
867
868    #[test]
869    fn parse_set_field_bool_value_becomes_bool() {
870        let (_name, value) = parse_set_field("Enabled=true").unwrap();
871        assert_eq!(value, serde_yaml::Value::Bool(true));
872    }
873
874    #[test]
875    fn parse_set_field_preserves_spaces_in_name() {
876        let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
877        assert_eq!(name, "Planned / Unplanned Work");
878        assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
879    }
880
881    #[test]
882    fn parse_set_field_equals_in_value_preserved() {
883        // Only the FIRST `=` splits name from value.
884        let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
885        assert_eq!(name, "Formula");
886        assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
887    }
888
889    #[test]
890    fn parse_set_field_requires_equals() {
891        let err = parse_set_field("just-a-name").unwrap_err();
892        assert!(err.to_string().contains("expected --set-field"));
893    }
894
895    #[test]
896    fn parse_set_field_empty_name_errors() {
897        let err = parse_set_field("=value").unwrap_err();
898        assert!(err.to_string().contains("non-empty name"));
899    }
900
901    #[test]
902    fn merge_set_field_overrides_cli_wins() {
903        let mut frontmatter = BTreeMap::new();
904        frontmatter.insert(
905            "Priority".to_string(),
906            serde_yaml::Value::String("Low".to_string()),
907        );
908        frontmatter.insert(
909            "Keep".to_string(),
910            serde_yaml::Value::String("from-fm".to_string()),
911        );
912        let overrides = vec![(
913            "Priority".to_string(),
914            serde_yaml::Value::String("High".to_string()),
915        )];
916        let merged = merge_set_field_overrides(frontmatter, overrides);
917        assert_eq!(
918            merged.get("Priority"),
919            Some(&serde_yaml::Value::String("High".to_string()))
920        );
921        assert_eq!(
922            merged.get("Keep"),
923            Some(&serde_yaml::Value::String("from-fm".to_string()))
924        );
925    }
926
927    #[test]
928    fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
929        let mut frontmatter = BTreeMap::new();
930        frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
931        let merged = merge_set_field_overrides(frontmatter, vec![]);
932        assert_eq!(merged.len(), 1);
933        assert_eq!(
934            merged.get("K"),
935            Some(&serde_yaml::Value::String("v".to_string()))
936        );
937    }
938
939    #[test]
940    fn section_prefers_tag_id_over_name_lookup() {
941        // Name "Acceptance Criteria" matches two different IDs globally, but
942        // the section tag carries a specific ID so no ambiguity error.
943        let editmeta = meta(&[(
944            "customfield_19300",
945            "Acceptance Criteria",
946            "string",
947            Some(CUSTOM_TEXTAREA),
948        )]);
949        let sections = [CustomFieldSection {
950            name: "Acceptance Criteria".to_string(),
951            id: "customfield_19300".to_string(),
952            body: "body".to_string(),
953        }];
954        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
955        assert!(out.contains_key("customfield_19300"));
956    }
957
958    // ── convert_textarea_string_values ────────────────────────────────
959
960    #[test]
961    fn convert_textarea_string_value_converts_to_adf() {
962        let editmeta = meta(&[(
963            "customfield_19300",
964            "Acceptance Criteria",
965            "string",
966            Some(CUSTOM_TEXTAREA),
967        )]);
968        let mut fields = BTreeMap::new();
969        fields.insert(
970            "customfield_19300".to_string(),
971            serde_json::Value::String("- one\n- two".to_string()),
972        );
973        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
974        let value = fields.get("customfield_19300").unwrap();
975        assert_eq!(value["type"], "doc");
976        assert_eq!(value["version"], 1);
977        assert!(value["content"].is_array());
978    }
979
980    #[test]
981    fn convert_textarea_object_value_passes_through() {
982        let editmeta = meta(&[(
983            "customfield_19300",
984            "Acceptance Criteria",
985            "string",
986            Some(CUSTOM_TEXTAREA),
987        )]);
988        let raw_adf = serde_json::json!({
989            "version": 1,
990            "type": "doc",
991            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
992        });
993        let mut fields = BTreeMap::new();
994        fields.insert("customfield_19300".to_string(), raw_adf.clone());
995        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
996        assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
997    }
998
999    #[test]
1000    fn convert_textarea_empty_string_clears_field() {
1001        let editmeta = meta(&[(
1002            "customfield_19300",
1003            "Acceptance Criteria",
1004            "string",
1005            Some(CUSTOM_TEXTAREA),
1006        )]);
1007        let mut fields = BTreeMap::new();
1008        fields.insert(
1009            "customfield_19300".to_string(),
1010            serde_json::Value::String(String::new()),
1011        );
1012        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1013        assert_eq!(
1014            fields.get("customfield_19300").unwrap(),
1015            &serde_json::Value::Null
1016        );
1017    }
1018
1019    #[test]
1020    fn convert_non_textarea_string_passes_through() {
1021        let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
1022        let mut fields = BTreeMap::new();
1023        fields.insert(
1024            "customfield_10010".to_string(),
1025            serde_json::Value::String("plain".to_string()),
1026        );
1027        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1028        assert_eq!(
1029            fields.get("customfield_10010").unwrap(),
1030            &serde_json::Value::String("plain".to_string())
1031        );
1032    }
1033
1034    #[test]
1035    fn convert_unknown_field_passes_through() {
1036        // Field id not present in editmeta — leave the value alone and let the
1037        // API surface its own error.
1038        let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
1039        let mut fields = BTreeMap::new();
1040        fields.insert(
1041            "customfield_99999".to_string(),
1042            serde_json::Value::String("- a".to_string()),
1043        );
1044        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1045        assert_eq!(
1046            fields.get("customfield_99999").unwrap(),
1047            &serde_json::Value::String("- a".to_string())
1048        );
1049    }
1050
1051    #[test]
1052    fn convert_textarea_non_string_non_object_passes_through() {
1053        // Numbers, bools, arrays, nulls are not coerced — those are not
1054        // legitimate textarea payloads and the API will tell the caller.
1055        let editmeta = meta(&[(
1056            "customfield_19300",
1057            "Acceptance Criteria",
1058            "string",
1059            Some(CUSTOM_TEXTAREA),
1060        )]);
1061        let mut fields = BTreeMap::new();
1062        fields.insert(
1063            "customfield_19300".to_string(),
1064            serde_json::Value::Number(42.into()),
1065        );
1066        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1067        assert_eq!(
1068            fields.get("customfield_19300").unwrap(),
1069            &serde_json::Value::Number(42.into())
1070        );
1071    }
1072
1073    #[test]
1074    fn convert_textarea_invalid_adf_nesting_errors() {
1075        let editmeta = meta(&[(
1076            "customfield_19300",
1077            "Acceptance Criteria",
1078            "string",
1079            Some(CUSTOM_TEXTAREA),
1080        )]);
1081        let mut fields = BTreeMap::new();
1082        fields.insert(
1083            "customfield_19300".to_string(),
1084            serde_json::Value::String(
1085                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1086            ),
1087        );
1088        let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
1089        let msg = format!("{err:#}");
1090        assert!(msg.contains("Acceptance Criteria"));
1091        assert!(msg.contains("ADF nesting validation"));
1092        assert!(msg.contains("`expand` cannot be a child of `panel`"));
1093    }
1094}