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, &id).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    id: &str,
227) -> Result<serde_json::Value> {
228    let kind = field.schema.kind.as_str();
229    let custom = field.schema.custom.as_deref();
230    match (kind, custom) {
231        ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
232            let s = yaml_as_string(value).with_context(|| {
233                format!("expected a string for option field '{}'", field.name)
234            })?;
235            validate_option_value(field, id, &s)?;
236            Ok(serde_json::json!({ "value": s }))
237        }
238        ("array", _) => {
239            let seq = value.as_sequence().ok_or_else(|| {
240                anyhow!("expected a sequence for array field '{}'", field.name)
241            })?;
242            let items: Vec<serde_json::Value> = seq
243                .iter()
244                .map(|v| {
245                    let s = yaml_as_string(v).with_context(|| {
246                        format!(
247                            "expected a string array element for field '{}'",
248                            field.name
249                        )
250                    })?;
251                    validate_option_value(field, id, &s)?;
252                    Ok(serde_json::json!({ "value": s }))
253                })
254                .collect::<Result<_>>()?;
255            Ok(serde_json::Value::Array(items))
256        }
257        ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
258        (other, _) => Err(anyhow!(
259            "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
260            field.name
261        )),
262    }
263}
264
265/// Validates a supplied option string against a field's enumerated
266/// `allowedValues`, when the meta reports them.
267///
268/// Matching is exact and case-sensitive — JIRA's own contract for option
269/// `value`s. Fuzzy matching is deliberately avoided: silently coercing a
270/// value the caller did not type is worse than a clear error.
271///
272/// When the field does not enumerate values — free text, numbers, user
273/// pickers, cascading selects — `allowed_values` is empty, so the check is
274/// skipped and the API performs final validation (surfaced verbatim as an
275/// HTTP error). This turns the common "typo'd select value → opaque HTTP 400"
276/// failure into an actionable, field-named error before the request.
277fn validate_option_value(field: &EditMetaField, id: &str, value: &str) -> Result<()> {
278    if field.allowed_values.is_empty() || field.allowed_values.iter().any(|v| v == value) {
279        return Ok(());
280    }
281    bail!(
282        "Field '{}' ({}): '{}' is not an allowed option. Valid options: {}",
283        field.name,
284        id,
285        value,
286        field.allowed_values.join(", ")
287    )
288}
289
290fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
291    match value {
292        serde_yaml::Value::String(s) => Ok(s.clone()),
293        serde_yaml::Value::Bool(b) => Ok(b.to_string()),
294        serde_yaml::Value::Number(n) => Ok(n.to_string()),
295        _ => Err(anyhow!("expected a scalar string value")),
296    }
297}
298
299fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
300    let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
301    serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
302        .context("Failed to convert YAML value to JSON")
303}
304
305/// Parses a `--set-field NAME=VALUE` argument into a `(name, value)` pair.
306///
307/// The value is parsed as YAML when possible so `--set-field "Points=8"`
308/// becomes a number and `--set-field "Enabled=true"` becomes a bool.
309/// Values that fail to parse as YAML fall back to plain strings.
310pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
311    let (name, value) = input
312        .split_once('=')
313        .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
314    let name = name.trim().to_string();
315    if name.is_empty() {
316        bail!("--set-field requires a non-empty name before '='");
317    }
318    let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
319        .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
320    Ok((name, yaml_value))
321}
322
323/// Translates an `accountId`-style assignee/reporter input to the JSON
324/// shape JIRA expects.
325///
326/// The empty string clears the user (Atlassian's supported `null` payload);
327/// any other value is wrapped as `{"accountId": "<value>"}`. The literal
328/// `-1` is preserved as `{"accountId": "-1"}`, which JIRA interprets as
329/// automatic assignment.
330pub fn user_field_value(raw: &str) -> serde_json::Value {
331    if raw.is_empty() {
332        serde_json::Value::Null
333    } else {
334        serde_json::json!({ "accountId": raw })
335    }
336}
337
338/// Merges typed `assignee`/`reporter` parameters into a resolved JIRA fields
339/// map.
340///
341/// Rejects collisions where the same field id has already been set
342/// (typically via the `fields` escape hatch on the MCP side or `--set-field`
343/// on the CLI side). `other_source_label` is interpolated into the error
344/// message to identify the colliding source — for example
345/// `the same key inside fields` or ``--set-field`` of the same name``.
346pub fn apply_user_field_overrides(
347    fields: &mut BTreeMap<String, serde_json::Value>,
348    assignee: Option<&str>,
349    reporter: Option<&str>,
350    other_source_label: &str,
351) -> Result<()> {
352    if let Some(value) = assignee {
353        if fields.contains_key("assignee") {
354            bail!("`assignee` collides with {other_source_label}; supply only one");
355        }
356        fields.insert("assignee".to_string(), user_field_value(value));
357    }
358    if let Some(value) = reporter {
359        if fields.contains_key("reporter") {
360            bail!("`reporter` collides with {other_source_label}; supply only one");
361        }
362        fields.insert("reporter".to_string(), user_field_value(value));
363    }
364    Ok(())
365}
366
367/// Merges CLI `--set-field` overrides into a frontmatter scalar map,
368/// with CLI overriding frontmatter on name conflicts.
369pub fn merge_set_field_overrides(
370    frontmatter: BTreeMap<String, serde_yaml::Value>,
371    overrides: Vec<(String, serde_yaml::Value)>,
372) -> BTreeMap<String, serde_yaml::Value> {
373    let mut merged = frontmatter;
374    for (name, value) in overrides {
375        merged.insert(name, value);
376    }
377    merged
378}
379
380#[cfg(test)]
381#[allow(clippy::unwrap_used, clippy::expect_used)]
382mod tests {
383    use super::*;
384    use crate::atlassian::client::{EditMetaField, EditMetaSchema};
385
386    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
387        let mut fields = BTreeMap::new();
388        for (id, name, kind, custom) in entries {
389            fields.insert(
390                (*id).to_string(),
391                EditMetaField {
392                    name: (*name).to_string(),
393                    schema: EditMetaSchema {
394                        kind: (*kind).to_string(),
395                        custom: custom.map(str::to_string),
396                    },
397                    allowed_values: Vec::new(),
398                },
399            );
400        }
401        EditMeta { fields }
402    }
403
404    /// Builds a single-field [`EditMeta`] for an option-like field that
405    /// enumerates `allowedValues`.
406    fn meta_with_allowed(id: &str, name: &str, kind: &str, allowed: &[&str]) -> EditMeta {
407        let mut fields = BTreeMap::new();
408        fields.insert(
409            id.to_string(),
410            EditMetaField {
411                name: name.to_string(),
412                schema: EditMetaSchema {
413                    kind: kind.to_string(),
414                    custom: None,
415                },
416                allowed_values: allowed.iter().map(|v| (*v).to_string()).collect(),
417            },
418        );
419        EditMeta { fields }
420    }
421
422    // ── user_field_value ──────────────────────────────────────
423
424    #[test]
425    fn user_field_value_empty_string_is_null() {
426        assert_eq!(user_field_value(""), serde_json::Value::Null);
427    }
428
429    #[test]
430    fn user_field_value_account_id_wrapped() {
431        assert_eq!(
432            user_field_value("abc123"),
433            serde_json::json!({"accountId": "abc123"})
434        );
435    }
436
437    #[test]
438    fn user_field_value_dash_one_preserves_auto_assign() {
439        assert_eq!(
440            user_field_value("-1"),
441            serde_json::json!({"accountId": "-1"})
442        );
443    }
444
445    // ── apply_user_field_overrides ────────────────────────────
446
447    #[test]
448    fn apply_user_field_overrides_inserts_assignee_and_reporter() {
449        let mut fields = BTreeMap::new();
450        apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
451        assert_eq!(
452            fields.get("assignee"),
453            Some(&serde_json::json!({"accountId": "a1"}))
454        );
455        assert_eq!(
456            fields.get("reporter"),
457            Some(&serde_json::json!({"accountId": "r1"}))
458        );
459    }
460
461    #[test]
462    fn apply_user_field_overrides_skips_none() {
463        let mut fields = BTreeMap::new();
464        apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
465        assert!(fields.is_empty());
466    }
467
468    #[test]
469    fn apply_user_field_overrides_empty_string_clears() {
470        let mut fields = BTreeMap::new();
471        apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
472        assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
473    }
474
475    #[test]
476    fn apply_user_field_overrides_assignee_collision_errors() {
477        let mut fields = BTreeMap::new();
478        fields.insert(
479            "assignee".to_string(),
480            serde_json::json!({"accountId": "existing"}),
481        );
482        let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
483            .unwrap_err();
484        let msg = err.to_string();
485        assert!(msg.contains("assignee"));
486        assert!(msg.contains("the test source"));
487    }
488
489    #[test]
490    fn apply_user_field_overrides_reporter_collision_errors() {
491        let mut fields = BTreeMap::new();
492        fields.insert(
493            "reporter".to_string(),
494            serde_json::json!({"accountId": "existing"}),
495        );
496        let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
497            .unwrap_err();
498        assert!(err.to_string().contains("reporter"));
499    }
500
501    #[test]
502    fn scalar_option_field_wraps_in_value_object() {
503        let editmeta = meta(&[(
504            "customfield_10001",
505            "Planned / Unplanned Work",
506            "option",
507            Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
508        )]);
509        let mut scalars = BTreeMap::new();
510        scalars.insert(
511            "Planned / Unplanned Work".to_string(),
512            serde_yaml::Value::String("Unplanned".to_string()),
513        );
514        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
515        assert_eq!(
516            out.get("customfield_10001").unwrap(),
517            &serde_json::json!({ "value": "Unplanned" })
518        );
519    }
520
521    #[test]
522    fn scalar_radiobutton_wraps_in_value_object() {
523        let editmeta = meta(&[(
524            "customfield_10002",
525            "Risk",
526            "string",
527            Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
528        )]);
529        let mut scalars = BTreeMap::new();
530        scalars.insert(
531            "Risk".to_string(),
532            serde_yaml::Value::String("High".to_string()),
533        );
534        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
535        assert_eq!(
536            out.get("customfield_10002").unwrap(),
537            &serde_json::json!({ "value": "High" })
538        );
539    }
540
541    #[test]
542    fn scalar_number_field_passes_through() {
543        let editmeta = meta(&[(
544            "customfield_10003",
545            "Story points",
546            "number",
547            Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
548        )]);
549        let mut scalars = BTreeMap::new();
550        scalars.insert(
551            "Story points".to_string(),
552            serde_yaml::Value::Number(8.into()),
553        );
554        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
555        assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
556    }
557
558    #[test]
559    fn scalar_array_option_field_wraps_each_item() {
560        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
561        let mut scalars = BTreeMap::new();
562        scalars.insert(
563            "Components".to_string(),
564            serde_yaml::Value::Sequence(vec![
565                serde_yaml::Value::String("backend".to_string()),
566                serde_yaml::Value::String("auth".to_string()),
567            ]),
568        );
569        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
570        assert_eq!(
571            out.get("customfield_10004").unwrap(),
572            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
573        );
574    }
575
576    #[test]
577    fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
578        // Issue #866: a string scalar targeting a textarea custom field is
579        // treated as JFM markdown and converted to ADF.
580        let editmeta = meta(&[(
581            "customfield_19300",
582            "Acceptance Criteria",
583            "string",
584            Some(CUSTOM_TEXTAREA),
585        )]);
586        let mut scalars = BTreeMap::new();
587        scalars.insert(
588            "Acceptance Criteria".to_string(),
589            serde_yaml::Value::String("- one\n- two".to_string()),
590        );
591        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
592        let value = out.get("customfield_19300").unwrap();
593        assert_eq!(value["type"], "doc");
594        assert_eq!(value["version"], 1);
595        assert!(value["content"].is_array());
596    }
597
598    #[test]
599    fn scalar_empty_string_to_rich_text_field_clears() {
600        let editmeta = meta(&[(
601            "customfield_19300",
602            "Acceptance Criteria",
603            "string",
604            Some(CUSTOM_TEXTAREA),
605        )]);
606        let mut scalars = BTreeMap::new();
607        scalars.insert(
608            "Acceptance Criteria".to_string(),
609            serde_yaml::Value::String(String::new()),
610        );
611        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
612        assert_eq!(
613            out.get("customfield_19300").unwrap(),
614            &serde_json::Value::Null
615        );
616    }
617
618    #[test]
619    fn scalar_yaml_null_to_rich_text_field_clears() {
620        // Distinct from the empty-string case: the CLI's `--set-field Name=`
621        // parses the empty RHS as YAML null (not a string), so this arm
622        // covers the production code path callers actually traverse to
623        // clear a rich-text field from the command line.
624        let editmeta = meta(&[(
625            "customfield_19300",
626            "Acceptance Criteria",
627            "string",
628            Some(CUSTOM_TEXTAREA),
629        )]);
630        let mut scalars = BTreeMap::new();
631        scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
632        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
633        assert_eq!(
634            out.get("customfield_19300").unwrap(),
635            &serde_json::Value::Null
636        );
637    }
638
639    #[test]
640    fn scalar_non_string_to_rich_text_field_errors() {
641        // Non-string scalars (numbers, bools, mappings, sequences) targeting
642        // a rich-text field still need a body section / JFM string.
643        let editmeta = meta(&[(
644            "customfield_19300",
645            "Acceptance Criteria",
646            "string",
647            Some(CUSTOM_TEXTAREA),
648        )]);
649        let mut scalars = BTreeMap::new();
650        scalars.insert(
651            "Acceptance Criteria".to_string(),
652            serde_yaml::Value::Number(42.into()),
653        );
654        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
655        let msg = err.to_string();
656        assert!(msg.contains("rich-text field"), "got: {msg}");
657        assert!(msg.contains("JFM markdown"), "got: {msg}");
658    }
659
660    #[test]
661    fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
662        let editmeta = meta(&[(
663            "customfield_19300",
664            "Acceptance Criteria",
665            "string",
666            Some(CUSTOM_TEXTAREA),
667        )]);
668        let mut scalars = BTreeMap::new();
669        scalars.insert(
670            "Acceptance Criteria".to_string(),
671            serde_yaml::Value::String(
672                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
673            ),
674        );
675        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
676        let msg = format!("{err:#}");
677        assert!(msg.contains("Acceptance Criteria"));
678        assert!(msg.contains("ADF nesting validation"));
679        assert!(msg.contains("`expand` cannot be a child of `panel`"));
680    }
681
682    #[test]
683    fn rich_text_section_becomes_adf_payload() {
684        let editmeta = meta(&[(
685            "customfield_19300",
686            "Acceptance Criteria",
687            "string",
688            Some(CUSTOM_TEXTAREA),
689        )]);
690        let sections = [CustomFieldSection {
691            name: "Acceptance Criteria".to_string(),
692            id: "customfield_19300".to_string(),
693            body: "- Item one\n- Item two".to_string(),
694        }];
695        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
696        let value = out.get("customfield_19300").unwrap();
697        assert_eq!(value["type"], "doc");
698        assert_eq!(value["version"], 1);
699        assert!(value["content"].is_array());
700    }
701
702    #[test]
703    fn rich_text_section_with_invalid_adf_nesting_errors() {
704        // Issue #714: a section whose body produces ADF that violates
705        // Confluence's nesting constraints (here panel→expand) must be
706        // rejected with the validation context, not silently included in the
707        // payload.
708        let editmeta = meta(&[(
709            "customfield_19300",
710            "Acceptance Criteria",
711            "string",
712            Some(CUSTOM_TEXTAREA),
713        )]);
714        let sections = [CustomFieldSection {
715            name: "Acceptance Criteria".to_string(),
716            id: "customfield_19300".to_string(),
717            body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
718        }];
719        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
720        let msg = format!("{err:#}");
721        assert!(msg.contains("Acceptance Criteria"));
722        assert!(msg.contains("ADF nesting validation"));
723        assert!(msg.contains("`expand` cannot be a child of `panel`"));
724    }
725
726    #[test]
727    fn section_pointing_at_non_rich_text_field_errors() {
728        let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
729        let sections = [CustomFieldSection {
730            name: "Priority Flag".to_string(),
731            id: "customfield_10001".to_string(),
732            body: "Some text".to_string(),
733        }];
734        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
735        assert!(err.to_string().contains("not a rich-text field"));
736    }
737
738    #[test]
739    fn unknown_field_name_errors_with_suggestions() {
740        let editmeta = meta(&[
741            ("customfield_1", "Alpha", "string", None),
742            ("customfield_2", "Beta", "string", None),
743        ]);
744        let mut scalars = BTreeMap::new();
745        scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
746        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
747        let msg = err.to_string();
748        assert!(msg.contains("Unknown custom field 'Gamma'"));
749        assert!(msg.contains("Alpha"));
750        assert!(msg.contains("Beta"));
751    }
752
753    #[test]
754    fn field_id_bypasses_name_lookup() {
755        let editmeta = meta(&[(
756            "customfield_10001",
757            "Planned / Unplanned Work",
758            "option",
759            None,
760        )]);
761        let mut scalars = BTreeMap::new();
762        scalars.insert(
763            "customfield_10001".to_string(),
764            serde_yaml::Value::String("Unplanned".to_string()),
765        );
766        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
767        assert_eq!(
768            out.get("customfield_10001").unwrap(),
769            &serde_json::json!({ "value": "Unplanned" })
770        );
771    }
772
773    #[test]
774    fn ambiguous_field_name_errors_listing_ids() {
775        let editmeta = meta(&[
776            ("customfield_1", "Duplicate", "string", None),
777            ("customfield_2", "Duplicate", "string", None),
778        ]);
779        let mut scalars = BTreeMap::new();
780        scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
781        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
782        let msg = err.to_string();
783        assert!(msg.contains("Ambiguous"));
784        assert!(msg.contains("customfield_1"));
785        assert!(msg.contains("customfield_2"));
786    }
787
788    #[test]
789    fn array_field_requires_sequence_value() {
790        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
791        let mut scalars = BTreeMap::new();
792        scalars.insert(
793            "Components".to_string(),
794            serde_yaml::Value::String("not-a-sequence".to_string()),
795        );
796        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
797        assert!(format!("{err:#}").contains("expected a sequence"));
798    }
799
800    #[test]
801    fn array_element_must_be_scalar_string() {
802        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
803        let mut scalars = BTreeMap::new();
804        scalars.insert(
805            "Components".to_string(),
806            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
807                serde_yaml::Value::String("nested".to_string()),
808            ])]),
809        );
810        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
811        assert!(format!("{err:#}").contains("expected a scalar string value"));
812    }
813
814    #[test]
815    fn unsupported_schema_type_errors_with_field_name() {
816        let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
817        let mut scalars = BTreeMap::new();
818        scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
819        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
820        let msg = format!("{err:#}");
821        assert!(msg.contains("Unsupported field type 'user'"));
822        assert!(msg.contains("Reporter"));
823    }
824
825    #[test]
826    fn option_field_accepts_bool_and_number_scalars() {
827        let editmeta = meta(&[
828            (
829                "customfield_bool",
830                "Toggle",
831                "option",
832                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
833            ),
834            (
835                "customfield_num",
836                "Number choice",
837                "option",
838                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
839            ),
840        ]);
841        let mut scalars = BTreeMap::new();
842        scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
843        scalars.insert(
844            "Number choice".to_string(),
845            serde_yaml::Value::Number(3.into()),
846        );
847        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
848        assert_eq!(
849            out.get("customfield_bool").unwrap(),
850            &serde_json::json!({"value": "true"})
851        );
852        assert_eq!(
853            out.get("customfield_num").unwrap(),
854            &serde_json::json!({"value": "3"})
855        );
856    }
857
858    #[test]
859    fn option_value_matching_allowed_values_passes() {
860        let editmeta = meta_with_allowed(
861            "customfield_21051",
862            "Work Attribution",
863            "option",
864            &["Planned", "Unplanned"],
865        );
866        let mut scalars = BTreeMap::new();
867        scalars.insert(
868            "Work Attribution".to_string(),
869            serde_yaml::Value::String("Planned".to_string()),
870        );
871        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
872        assert_eq!(
873            out.get("customfield_21051").unwrap(),
874            &serde_json::json!({ "value": "Planned" })
875        );
876    }
877
878    #[test]
879    fn option_value_not_in_allowed_values_errors_with_field_and_options() {
880        let editmeta = meta_with_allowed(
881            "customfield_21051",
882            "Work Attribution",
883            "option",
884            &["Planned", "Unplanned"],
885        );
886        let mut scalars = BTreeMap::new();
887        scalars.insert(
888            "Work Attribution".to_string(),
889            serde_yaml::Value::String("Plnned".to_string()),
890        );
891        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
892        let msg = format!("{err:#}");
893        assert!(msg.contains("Work Attribution"), "got: {msg}");
894        assert!(msg.contains("customfield_21051"), "got: {msg}");
895        assert!(
896            msg.contains("'Plnned' is not an allowed option"),
897            "got: {msg}"
898        );
899        assert!(msg.contains("Planned, Unplanned"), "got: {msg}");
900    }
901
902    #[test]
903    fn option_value_matching_is_case_sensitive() {
904        // "planned" != "Planned" — JIRA's option contract is case-sensitive,
905        // and fuzzy coercion is deliberately avoided.
906        let editmeta = meta_with_allowed(
907            "customfield_21051",
908            "Work Attribution",
909            "option",
910            &["Planned"],
911        );
912        let mut scalars = BTreeMap::new();
913        scalars.insert(
914            "Work Attribution".to_string(),
915            serde_yaml::Value::String("planned".to_string()),
916        );
917        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
918        assert!(format!("{err:#}").contains("not an allowed option"));
919    }
920
921    #[test]
922    fn option_field_without_allowed_values_passes_any_value_through() {
923        // No enumerated allowedValues: skip local validation, let the API decide.
924        let editmeta = meta(&[("customfield_21051", "Work Attribution", "option", None)]);
925        let mut scalars = BTreeMap::new();
926        scalars.insert(
927            "Work Attribution".to_string(),
928            serde_yaml::Value::String("Anything".to_string()),
929        );
930        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
931        assert_eq!(
932            out.get("customfield_21051").unwrap(),
933            &serde_json::json!({ "value": "Anything" })
934        );
935    }
936
937    #[test]
938    fn array_option_element_not_in_allowed_values_errors() {
939        let editmeta =
940            meta_with_allowed("customfield_10004", "Teams", "array", &["backend", "auth"]);
941        let mut scalars = BTreeMap::new();
942        scalars.insert(
943            "Teams".to_string(),
944            serde_yaml::Value::Sequence(vec![
945                serde_yaml::Value::String("backend".to_string()),
946                serde_yaml::Value::String("frontend".to_string()),
947            ]),
948        );
949        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
950        let msg = format!("{err:#}");
951        assert!(
952            msg.contains("'frontend' is not an allowed option"),
953            "got: {msg}"
954        );
955        assert!(msg.contains("backend, auth"), "got: {msg}");
956    }
957
958    #[test]
959    fn option_field_rejects_non_scalar_value() {
960        let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
961        let mut mapping = serde_yaml::Mapping::new();
962        mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
963        let mut scalars = BTreeMap::new();
964        scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
965        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
966        assert!(format!("{err:#}").contains("expected a scalar string value"));
967    }
968
969    #[test]
970    fn section_with_stale_id_falls_back_to_name_lookup() {
971        // editmeta has the field under a new id; the section tag carries an
972        // older id. Resolver should fall back to name lookup and find it.
973        let editmeta = meta(&[(
974            "customfield_NEW",
975            "Acceptance Criteria",
976            "string",
977            Some(CUSTOM_TEXTAREA),
978        )]);
979        let sections = [CustomFieldSection {
980            name: "Acceptance Criteria".to_string(),
981            id: "customfield_OLD".to_string(),
982            body: "body".to_string(),
983        }];
984        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
985        assert!(out.contains_key("customfield_NEW"));
986        assert!(!out.contains_key("customfield_OLD"));
987    }
988
989    #[test]
990    fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
991        // A `customfield_<digits>` key that isn't in editmeta should still
992        // try a name lookup before erroring.
993        let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
994        let mut scalars = BTreeMap::new();
995        scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
996        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
997        assert!(err.to_string().contains("Unknown custom field"));
998    }
999
1000    // ── parse_set_field / merge_set_field_overrides ─────────────────
1001
1002    #[test]
1003    fn parse_set_field_bare_string_value() {
1004        let (name, value) = parse_set_field("Status=Open").unwrap();
1005        assert_eq!(name, "Status");
1006        assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
1007    }
1008
1009    #[test]
1010    fn parse_set_field_numeric_value_becomes_number() {
1011        let (_name, value) = parse_set_field("Points=8").unwrap();
1012        assert_eq!(value, serde_yaml::Value::Number(8.into()));
1013    }
1014
1015    #[test]
1016    fn parse_set_field_bool_value_becomes_bool() {
1017        let (_name, value) = parse_set_field("Enabled=true").unwrap();
1018        assert_eq!(value, serde_yaml::Value::Bool(true));
1019    }
1020
1021    #[test]
1022    fn parse_set_field_preserves_spaces_in_name() {
1023        let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
1024        assert_eq!(name, "Planned / Unplanned Work");
1025        assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
1026    }
1027
1028    #[test]
1029    fn parse_set_field_equals_in_value_preserved() {
1030        // Only the FIRST `=` splits name from value.
1031        let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
1032        assert_eq!(name, "Formula");
1033        assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
1034    }
1035
1036    #[test]
1037    fn parse_set_field_requires_equals() {
1038        let err = parse_set_field("just-a-name").unwrap_err();
1039        assert!(err.to_string().contains("expected --set-field"));
1040    }
1041
1042    #[test]
1043    fn parse_set_field_empty_name_errors() {
1044        let err = parse_set_field("=value").unwrap_err();
1045        assert!(err.to_string().contains("non-empty name"));
1046    }
1047
1048    #[test]
1049    fn merge_set_field_overrides_cli_wins() {
1050        let mut frontmatter = BTreeMap::new();
1051        frontmatter.insert(
1052            "Priority".to_string(),
1053            serde_yaml::Value::String("Low".to_string()),
1054        );
1055        frontmatter.insert(
1056            "Keep".to_string(),
1057            serde_yaml::Value::String("from-fm".to_string()),
1058        );
1059        let overrides = vec![(
1060            "Priority".to_string(),
1061            serde_yaml::Value::String("High".to_string()),
1062        )];
1063        let merged = merge_set_field_overrides(frontmatter, overrides);
1064        assert_eq!(
1065            merged.get("Priority"),
1066            Some(&serde_yaml::Value::String("High".to_string()))
1067        );
1068        assert_eq!(
1069            merged.get("Keep"),
1070            Some(&serde_yaml::Value::String("from-fm".to_string()))
1071        );
1072    }
1073
1074    #[test]
1075    fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
1076        let mut frontmatter = BTreeMap::new();
1077        frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
1078        let merged = merge_set_field_overrides(frontmatter, vec![]);
1079        assert_eq!(merged.len(), 1);
1080        assert_eq!(
1081            merged.get("K"),
1082            Some(&serde_yaml::Value::String("v".to_string()))
1083        );
1084    }
1085
1086    #[test]
1087    fn section_prefers_tag_id_over_name_lookup() {
1088        // Name "Acceptance Criteria" matches two different IDs globally, but
1089        // the section tag carries a specific ID so no ambiguity error.
1090        let editmeta = meta(&[(
1091            "customfield_19300",
1092            "Acceptance Criteria",
1093            "string",
1094            Some(CUSTOM_TEXTAREA),
1095        )]);
1096        let sections = [CustomFieldSection {
1097            name: "Acceptance Criteria".to_string(),
1098            id: "customfield_19300".to_string(),
1099            body: "body".to_string(),
1100        }];
1101        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
1102        assert!(out.contains_key("customfield_19300"));
1103    }
1104
1105    // ── convert_textarea_string_values ────────────────────────────────
1106
1107    #[test]
1108    fn convert_textarea_string_value_converts_to_adf() {
1109        let editmeta = meta(&[(
1110            "customfield_19300",
1111            "Acceptance Criteria",
1112            "string",
1113            Some(CUSTOM_TEXTAREA),
1114        )]);
1115        let mut fields = BTreeMap::new();
1116        fields.insert(
1117            "customfield_19300".to_string(),
1118            serde_json::Value::String("- one\n- two".to_string()),
1119        );
1120        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1121        let value = fields.get("customfield_19300").unwrap();
1122        assert_eq!(value["type"], "doc");
1123        assert_eq!(value["version"], 1);
1124        assert!(value["content"].is_array());
1125    }
1126
1127    #[test]
1128    fn convert_textarea_object_value_passes_through() {
1129        let editmeta = meta(&[(
1130            "customfield_19300",
1131            "Acceptance Criteria",
1132            "string",
1133            Some(CUSTOM_TEXTAREA),
1134        )]);
1135        let raw_adf = serde_json::json!({
1136            "version": 1,
1137            "type": "doc",
1138            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
1139        });
1140        let mut fields = BTreeMap::new();
1141        fields.insert("customfield_19300".to_string(), raw_adf.clone());
1142        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1143        assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
1144    }
1145
1146    #[test]
1147    fn convert_textarea_empty_string_clears_field() {
1148        let editmeta = meta(&[(
1149            "customfield_19300",
1150            "Acceptance Criteria",
1151            "string",
1152            Some(CUSTOM_TEXTAREA),
1153        )]);
1154        let mut fields = BTreeMap::new();
1155        fields.insert(
1156            "customfield_19300".to_string(),
1157            serde_json::Value::String(String::new()),
1158        );
1159        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1160        assert_eq!(
1161            fields.get("customfield_19300").unwrap(),
1162            &serde_json::Value::Null
1163        );
1164    }
1165
1166    #[test]
1167    fn convert_non_textarea_string_passes_through() {
1168        let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
1169        let mut fields = BTreeMap::new();
1170        fields.insert(
1171            "customfield_10010".to_string(),
1172            serde_json::Value::String("plain".to_string()),
1173        );
1174        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1175        assert_eq!(
1176            fields.get("customfield_10010").unwrap(),
1177            &serde_json::Value::String("plain".to_string())
1178        );
1179    }
1180
1181    #[test]
1182    fn convert_unknown_field_passes_through() {
1183        // Field id not present in editmeta — leave the value alone and let the
1184        // API surface its own error.
1185        let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
1186        let mut fields = BTreeMap::new();
1187        fields.insert(
1188            "customfield_99999".to_string(),
1189            serde_json::Value::String("- a".to_string()),
1190        );
1191        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1192        assert_eq!(
1193            fields.get("customfield_99999").unwrap(),
1194            &serde_json::Value::String("- a".to_string())
1195        );
1196    }
1197
1198    #[test]
1199    fn convert_textarea_non_string_non_object_passes_through() {
1200        // Numbers, bools, arrays, nulls are not coerced — those are not
1201        // legitimate textarea payloads and the API will tell the caller.
1202        let editmeta = meta(&[(
1203            "customfield_19300",
1204            "Acceptance Criteria",
1205            "string",
1206            Some(CUSTOM_TEXTAREA),
1207        )]);
1208        let mut fields = BTreeMap::new();
1209        fields.insert(
1210            "customfield_19300".to_string(),
1211            serde_json::Value::Number(42.into()),
1212        );
1213        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1214        assert_eq!(
1215            fields.get("customfield_19300").unwrap(),
1216            &serde_json::Value::Number(42.into())
1217        );
1218    }
1219
1220    #[test]
1221    fn convert_textarea_invalid_adf_nesting_errors() {
1222        let editmeta = meta(&[(
1223            "customfield_19300",
1224            "Acceptance Criteria",
1225            "string",
1226            Some(CUSTOM_TEXTAREA),
1227        )]);
1228        let mut fields = BTreeMap::new();
1229        fields.insert(
1230            "customfield_19300".to_string(),
1231            serde_json::Value::String(
1232                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1233            ),
1234        );
1235        let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
1236        let msg = format!("{err:#}");
1237        assert!(msg.contains("Acceptance Criteria"));
1238        assert!(msg.contains("ADF nesting validation"));
1239        assert!(msg.contains("`expand` cannot be a child of `panel`"));
1240    }
1241}