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::AdfDocument;
16use crate::atlassian::adf_validated::{markdown_to_validated_adf, ValidatedAdfDocument};
17use crate::atlassian::document::CustomFieldSection;
18use crate::atlassian::jira_types::{EditMeta, EditMetaField};
19
20#[cfg(test)]
21use crate::atlassian::jira_types::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). Array fields
29///   take a YAML sequence or a comma-separated string (elements trimmed,
30///   empties dropped).
31/// - **Sections** must reference rich-text fields; their markdown is
32///   converted to validated ADF via [`markdown_to_validated_adf`].
33///
34/// Field names are looked up in [`EditMeta`]; entries already formatted as
35/// `customfield_<digits>` bypass the lookup. An unknown or ambiguous name
36/// produces an error naming the available editable fields.
37pub fn resolve_custom_fields(
38    scalars: &BTreeMap<String, serde_yaml::Value>,
39    sections: &[CustomFieldSection],
40    editmeta: &EditMeta,
41) -> Result<BTreeMap<String, serde_json::Value>> {
42    let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
43
44    for (key, value) in scalars {
45        let (id, field) = lookup_field(editmeta, key)?;
46        if field.is_adf_rich_text() {
47            let payload = rich_text_scalar_to_api_value(value, field, &id)?;
48            out.insert(id, payload);
49            continue;
50        }
51        let payload = scalar_to_api_value(value, field, &id).with_context(|| {
52            format!(
53                "Failed to convert custom field '{}' ({}) to API value",
54                field.name, id
55            )
56        })?;
57        out.insert(id, payload);
58    }
59
60    for section in sections {
61        let (id, field) = resolve_section_field(editmeta, section)?;
62        if !field.is_adf_rich_text() {
63            bail!(
64                "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
65                field.name, id
66            );
67        }
68        let validated = markdown_to_validated_adf(&section.body).with_context(|| {
69            format!(
70                "Custom field '{}' ({}) failed ADF nesting validation",
71                field.name, id
72            )
73        })?;
74        let value = serde_json::to_value(&validated)
75            .context("Failed to serialize custom field ADF document")?;
76        out.insert(id, value);
77    }
78
79    Ok(out)
80}
81
82/// Looks up a field by id-or-name, preferring an exact id match
83/// (`customfield_<id>` or a system id like `labels`/`parent`) before falling
84/// back to a name lookup. An exact id therefore shadows an identically
85/// spelled display name.
86fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
87    if let Some(field) = editmeta.fields.get(key) {
88        return Ok((key.to_string(), field));
89    }
90
91    let matches: Vec<_> = editmeta
92        .fields
93        .iter()
94        .filter(|(_, f)| f.name == key)
95        .collect();
96
97    match matches.as_slice() {
98        [] => {
99            let candidates = editmeta
100                .fields
101                .iter()
102                .map(|(id, f)| format!("  {id}  {}", f.name))
103                .collect::<Vec<_>>()
104                .join("\n");
105            Err(anyhow!(
106                "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
107            ))
108        }
109        [(id, field)] => Ok(((*id).clone(), field)),
110        multi => {
111            let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
112            Err(anyhow!(
113                "Ambiguous custom field '{key}' matches multiple IDs: {}",
114                ids.join(", ")
115            ))
116        }
117    }
118}
119
120/// Resolves a body section's tag (which carries both name and id) against
121/// editmeta, trusting the id when both are present.
122fn resolve_section_field<'a>(
123    editmeta: &'a EditMeta,
124    section: &CustomFieldSection,
125) -> Result<(String, &'a EditMetaField)> {
126    if let Some(field) = editmeta.fields.get(&section.id) {
127        return Ok((section.id.clone(), field));
128    }
129    lookup_field(editmeta, &section.name)
130}
131
132/// Converts a frontmatter / `--set-field` scalar targeting a rich-text custom
133/// field into the API JSON shape.
134///
135/// String values are treated as JFM markdown and converted to ADF (matching
136/// the contract for `content`/description and for body sections). An empty
137/// string or YAML null clears the field by emitting `null`. A mapping is
138/// treated as a raw ADF document: it must carry `type: doc` and pass nesting
139/// validation, and is then forwarded as-is (the escape hatch for structured
140/// rich-text content that JFM cannot express). Other scalars (numbers,
141/// bools, sequences) are rejected.
142///
143/// Null handling is load-bearing for the CLI: `--set-field "Name="` parses
144/// the empty RHS as YAML null (not a string), so a "clear the field"
145/// invocation arrives here as `Value::Null`.
146fn rich_text_scalar_to_api_value(
147    value: &serde_yaml::Value,
148    field: &EditMetaField,
149    id: &str,
150) -> Result<serde_json::Value> {
151    let s = match value {
152        serde_yaml::Value::String(s) => s.clone(),
153        serde_yaml::Value::Null => String::new(),
154        serde_yaml::Value::Mapping(_) => return adf_mapping_to_api_value(value, field, id),
155        _ => bail!(
156            "Field '{}' ({}) is a rich-text field; supply JFM markdown as a string or use a `<!-- field: {} ({}) -->` body section",
157            field.name,
158            id,
159            field.name,
160            id
161        ),
162    };
163    string_to_rich_text_api_value(&s, &field.name, id)
164}
165
166/// Validates and forwards a raw ADF document supplied for a rich-text field.
167fn adf_mapping_to_api_value(
168    value: &serde_yaml::Value,
169    field: &EditMetaField,
170    id: &str,
171) -> Result<serde_json::Value> {
172    let json = yaml_to_json(value)?;
173    if json.get("type").and_then(serde_json::Value::as_str) != Some("doc") {
174        bail!(
175            "Field '{}' ({}) is a rich-text field; a mapping value must be a raw ADF document with `type: doc` (or supply JFM markdown as a string)",
176            field.name,
177            id
178        );
179    }
180    let doc: AdfDocument = serde_json::from_value(json).with_context(|| {
181        format!(
182            "Custom field '{}' ({}) value is not a valid ADF document",
183            field.name, id
184        )
185    })?;
186    let validated = ValidatedAdfDocument::try_new(doc).with_context(|| {
187        format!(
188            "Custom field '{}' ({}) failed ADF nesting validation",
189            field.name, id
190        )
191    })?;
192    serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
193}
194
195/// Shared conversion: empty → `null`; otherwise JFM → validated ADF JSON.
196fn string_to_rich_text_api_value(s: &str, field_name: &str, id: &str) -> Result<serde_json::Value> {
197    if s.is_empty() {
198        return Ok(serde_json::Value::Null);
199    }
200    let validated = markdown_to_validated_adf(s).with_context(|| {
201        format!("Custom field '{field_name}' ({id}) failed ADF nesting validation")
202    })?;
203    serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
204}
205
206/// Applies JFM → ADF conversion in-place to string values targeting rich-text
207/// custom fields, per issue #866.
208///
209/// For each entry in `fields`:
210/// - If the key is not present in `editmeta.fields`, leave the value
211///   untouched (pass-through — the API will surface its own error).
212/// - If the resolved field is not a rich-text textarea, leave the value
213///   untouched.
214/// - If the value is a JSON object, leave it untouched (assumed to be a raw
215///   ADF document — backwards-compatible).
216/// - If the value is a JSON string, treat it as JFM markdown and convert.
217///   An empty string becomes `null`, which clears the field.
218/// - Any other value type (number/bool/array/null) is left untouched.
219///
220/// Designed for the MCP `jira_write` `fields` escape hatch: lets callers pass
221/// `"customfield_19300": "- bullet\n- bullet"` and get the right ADF on the
222/// wire without hand-crafting the document.
223pub fn convert_textarea_string_values(
224    fields: &mut BTreeMap<String, serde_json::Value>,
225    editmeta: &EditMeta,
226) -> Result<()> {
227    for (id, value) in fields.iter_mut() {
228        let Some(field) = editmeta.fields.get(id) else {
229            continue;
230        };
231        if !field.is_adf_rich_text() {
232            continue;
233        }
234        let serde_json::Value::String(s) = value else {
235            continue;
236        };
237        *value = string_to_rich_text_api_value(s, &field.name, id)?;
238    }
239    Ok(())
240}
241
242/// Dispatches a scalar YAML value to the API shape expected for a given
243/// field schema.
244fn scalar_to_api_value(
245    value: &serde_yaml::Value,
246    field: &EditMetaField,
247    id: &str,
248) -> Result<serde_json::Value> {
249    let kind = field.schema.kind.as_str();
250    let custom = field.schema.custom.as_deref();
251    match (kind, custom) {
252        ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
253            let s = yaml_as_string(value).with_context(|| {
254                format!("expected a string for option field '{}'", field.name)
255            })?;
256            validate_option_value(field, id, &s)?;
257            Ok(serde_json::json!({ "value": s }))
258        }
259        ("array", _) => {
260            // Element shape depends on the array's item type: labels and
261            // labels-like custom fields take bare strings, components and
262            // versions take `{"name"}`, options (the historical default)
263            // take `{"value"}`.
264            let wrap: fn(String) -> serde_json::Value = match field.schema.items.as_deref() {
265                Some("string") => serde_json::Value::String,
266                Some("component" | "version") => |s| serde_json::json!({ "name": s }),
267                _ => |s| serde_json::json!({ "value": s }),
268            };
269            // A scalar is accepted as comma-separated shorthand for a
270            // sequence (split on ',', trim, drop empties), so
271            // `Labels=a,b,c` works alongside `Labels=[a, b, c]`. Sequence
272            // elements are never split — `Labels=["a,b"]` is the escape
273            // hatch for a literal comma inside one element.
274            let elements: Vec<String> = match value {
275                serde_yaml::Value::Sequence(seq) => seq
276                    .iter()
277                    .map(|v| {
278                        yaml_as_string(v).with_context(|| {
279                            format!(
280                                "expected a string array element for field '{}'",
281                                field.name
282                            )
283                        })
284                    })
285                    .collect::<Result<_>>()?,
286                serde_yaml::Value::String(_)
287                | serde_yaml::Value::Number(_)
288                | serde_yaml::Value::Bool(_) => yaml_as_string(value)?
289                    .split(',')
290                    .map(str::trim)
291                    .filter(|part| !part.is_empty())
292                    .map(String::from)
293                    .collect(),
294                _ => bail!(
295                    "expected a sequence (e.g. [a, b]) or comma-separated string (e.g. a,b) for array field '{}'",
296                    field.name
297                ),
298            };
299            let items: Vec<serde_json::Value> = elements
300                .into_iter()
301                .map(|s| {
302                    validate_option_value(field, id, &s)?;
303                    Ok(wrap(s))
304                })
305                .collect::<Result<_>>()?;
306            Ok(serde_json::Value::Array(items))
307        }
308        ("issuelink", _) => match value {
309            serde_yaml::Value::String(s) => Ok(serde_json::json!({ "key": s })),
310            // Pre-shaped payloads ({"key": ...} / {"id": ...}) pass through.
311            serde_yaml::Value::Mapping(_) => yaml_to_json(value),
312            _ => Err(anyhow!(
313                "expected an issue key string (e.g. 'PROJ-123') for issue-link field '{}'",
314                field.name
315            )),
316        },
317        ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
318        (other, _) => Err(anyhow!(
319            "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, issue-link, and arrays of options, strings/labels, components, or versions",
320            field.name
321        )),
322    }
323}
324
325/// Validates a supplied option string against a field's enumerated
326/// `allowedValues`, when the meta reports them.
327///
328/// Matching is exact and case-sensitive — JIRA's own contract for option
329/// `value`s. Fuzzy matching is deliberately avoided: silently coercing a
330/// value the caller did not type is worse than a clear error.
331///
332/// When the field does not enumerate values — free text, numbers, user
333/// pickers, cascading selects — `allowed_values` is empty, so the check is
334/// skipped and the API performs final validation (surfaced verbatim as an
335/// HTTP error). This turns the common "typo'd select value → opaque HTTP 400"
336/// failure into an actionable, field-named error before the request.
337fn validate_option_value(field: &EditMetaField, id: &str, value: &str) -> Result<()> {
338    if field.allowed_values.is_empty() || field.allowed_values.iter().any(|v| v == value) {
339        return Ok(());
340    }
341    bail!(
342        "Field '{}' ({}): '{}' is not an allowed option. Valid options: {}",
343        field.name,
344        id,
345        value,
346        field.allowed_values.join(", ")
347    )
348}
349
350fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
351    match value {
352        serde_yaml::Value::String(s) => Ok(s.clone()),
353        serde_yaml::Value::Bool(b) => Ok(b.to_string()),
354        serde_yaml::Value::Number(n) => Ok(n.to_string()),
355        _ => Err(anyhow!("expected a scalar string value")),
356    }
357}
358
359fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
360    let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
361    serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
362        .context("Failed to convert YAML value to JSON")
363}
364
365/// Parses a `--set-field NAME=VALUE` argument into a `(name, value)` pair.
366///
367/// The value is parsed as YAML when possible so `--set-field "Points=8"`
368/// becomes a number and `--set-field "Enabled=true"` becomes a bool.
369/// Values that fail to parse as YAML fall back to plain strings.
370pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
371    let (name, value) = input
372        .split_once('=')
373        .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
374    let name = name.trim().to_string();
375    if name.is_empty() {
376        bail!("--set-field requires a non-empty name before '='");
377    }
378    let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
379        .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
380    Ok((name, yaml_value))
381}
382
383/// Translates an `accountId`-style assignee/reporter input to the JSON
384/// shape JIRA expects.
385///
386/// The empty string clears the user (Atlassian's supported `null` payload);
387/// any other value is wrapped as `{"accountId": "<value>"}`. The literal
388/// `-1` is preserved as `{"accountId": "-1"}`, which JIRA interprets as
389/// automatic assignment.
390pub fn user_field_value(raw: &str) -> serde_json::Value {
391    if raw.is_empty() {
392        serde_json::Value::Null
393    } else {
394        serde_json::json!({ "accountId": raw })
395    }
396}
397
398/// Merges typed `assignee`/`reporter` parameters into a resolved JIRA fields
399/// map.
400///
401/// Rejects collisions where the same field id has already been set
402/// (typically via the `fields` escape hatch on the MCP side or `--set-field`
403/// on the CLI side). `other_source_label` is interpolated into the error
404/// message to identify the colliding source — for example
405/// `the same key inside fields` or ``--set-field`` of the same name``.
406pub fn apply_user_field_overrides(
407    fields: &mut BTreeMap<String, serde_json::Value>,
408    assignee: Option<&str>,
409    reporter: Option<&str>,
410    other_source_label: &str,
411) -> Result<()> {
412    if let Some(value) = assignee {
413        if fields.contains_key("assignee") {
414            bail!("`assignee` collides with {other_source_label}; supply only one");
415        }
416        fields.insert("assignee".to_string(), user_field_value(value));
417    }
418    if let Some(value) = reporter {
419        if fields.contains_key("reporter") {
420            bail!("`reporter` collides with {other_source_label}; supply only one");
421        }
422        fields.insert("reporter".to_string(), user_field_value(value));
423    }
424    Ok(())
425}
426
427/// Merges CLI `--set-field` overrides into a frontmatter scalar map,
428/// with CLI overriding frontmatter on name conflicts.
429pub fn merge_set_field_overrides(
430    frontmatter: BTreeMap<String, serde_yaml::Value>,
431    overrides: Vec<(String, serde_yaml::Value)>,
432) -> BTreeMap<String, serde_yaml::Value> {
433    let mut merged = frontmatter;
434    for (name, value) in overrides {
435        merged.insert(name, value);
436    }
437    merged
438}
439
440#[cfg(test)]
441#[allow(clippy::unwrap_used, clippy::expect_used)]
442mod tests {
443    use super::*;
444    use crate::atlassian::jira_types::{EditMetaField, EditMetaSchema};
445
446    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
447        let mut fields = BTreeMap::new();
448        for (id, name, kind, custom) in entries {
449            fields.insert(
450                (*id).to_string(),
451                EditMetaField {
452                    name: (*name).to_string(),
453                    schema: EditMetaSchema {
454                        kind: (*kind).to_string(),
455                        custom: custom.map(str::to_string),
456                        ..EditMetaSchema::default()
457                    },
458                    allowed_values: Vec::new(),
459                },
460            );
461        }
462        EditMeta { fields }
463    }
464
465    /// Builds a single-field [`EditMeta`] from a full [`EditMetaSchema`], for
466    /// tests exercising `items`/`system` dispatch.
467    fn meta_with_schema(id: &str, name: &str, schema: EditMetaSchema) -> EditMeta {
468        let mut fields = BTreeMap::new();
469        fields.insert(
470            id.to_string(),
471            EditMetaField {
472                name: name.to_string(),
473                schema,
474                allowed_values: Vec::new(),
475            },
476        );
477        EditMeta { fields }
478    }
479
480    /// Builds a single-field [`EditMeta`] for an option-like field that
481    /// enumerates `allowedValues`.
482    fn meta_with_allowed(id: &str, name: &str, kind: &str, allowed: &[&str]) -> EditMeta {
483        let mut fields = BTreeMap::new();
484        fields.insert(
485            id.to_string(),
486            EditMetaField {
487                name: name.to_string(),
488                schema: EditMetaSchema {
489                    kind: kind.to_string(),
490                    ..EditMetaSchema::default()
491                },
492                allowed_values: allowed.iter().map(|v| (*v).to_string()).collect(),
493            },
494        );
495        EditMeta { fields }
496    }
497
498    // ── user_field_value ──────────────────────────────────────
499
500    #[test]
501    fn user_field_value_empty_string_is_null() {
502        assert_eq!(user_field_value(""), serde_json::Value::Null);
503    }
504
505    #[test]
506    fn user_field_value_account_id_wrapped() {
507        assert_eq!(
508            user_field_value("abc123"),
509            serde_json::json!({"accountId": "abc123"})
510        );
511    }
512
513    #[test]
514    fn user_field_value_dash_one_preserves_auto_assign() {
515        assert_eq!(
516            user_field_value("-1"),
517            serde_json::json!({"accountId": "-1"})
518        );
519    }
520
521    // ── apply_user_field_overrides ────────────────────────────
522
523    #[test]
524    fn apply_user_field_overrides_inserts_assignee_and_reporter() {
525        let mut fields = BTreeMap::new();
526        apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
527        assert_eq!(
528            fields.get("assignee"),
529            Some(&serde_json::json!({"accountId": "a1"}))
530        );
531        assert_eq!(
532            fields.get("reporter"),
533            Some(&serde_json::json!({"accountId": "r1"}))
534        );
535    }
536
537    #[test]
538    fn apply_user_field_overrides_skips_none() {
539        let mut fields = BTreeMap::new();
540        apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
541        assert!(fields.is_empty());
542    }
543
544    #[test]
545    fn apply_user_field_overrides_empty_string_clears() {
546        let mut fields = BTreeMap::new();
547        apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
548        assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
549    }
550
551    #[test]
552    fn apply_user_field_overrides_assignee_collision_errors() {
553        let mut fields = BTreeMap::new();
554        fields.insert(
555            "assignee".to_string(),
556            serde_json::json!({"accountId": "existing"}),
557        );
558        let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
559            .unwrap_err();
560        let msg = err.to_string();
561        assert!(msg.contains("assignee"));
562        assert!(msg.contains("the test source"));
563    }
564
565    #[test]
566    fn apply_user_field_overrides_reporter_collision_errors() {
567        let mut fields = BTreeMap::new();
568        fields.insert(
569            "reporter".to_string(),
570            serde_json::json!({"accountId": "existing"}),
571        );
572        let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
573            .unwrap_err();
574        assert!(err.to_string().contains("reporter"));
575    }
576
577    #[test]
578    fn scalar_option_field_wraps_in_value_object() {
579        let editmeta = meta(&[(
580            "customfield_10001",
581            "Planned / Unplanned Work",
582            "option",
583            Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
584        )]);
585        let mut scalars = BTreeMap::new();
586        scalars.insert(
587            "Planned / Unplanned Work".to_string(),
588            serde_yaml::Value::String("Unplanned".to_string()),
589        );
590        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
591        assert_eq!(
592            out.get("customfield_10001").unwrap(),
593            &serde_json::json!({ "value": "Unplanned" })
594        );
595    }
596
597    #[test]
598    fn scalar_radiobutton_wraps_in_value_object() {
599        let editmeta = meta(&[(
600            "customfield_10002",
601            "Risk",
602            "string",
603            Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
604        )]);
605        let mut scalars = BTreeMap::new();
606        scalars.insert(
607            "Risk".to_string(),
608            serde_yaml::Value::String("High".to_string()),
609        );
610        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
611        assert_eq!(
612            out.get("customfield_10002").unwrap(),
613            &serde_json::json!({ "value": "High" })
614        );
615    }
616
617    #[test]
618    fn scalar_number_field_passes_through() {
619        let editmeta = meta(&[(
620            "customfield_10003",
621            "Story points",
622            "number",
623            Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
624        )]);
625        let mut scalars = BTreeMap::new();
626        scalars.insert(
627            "Story points".to_string(),
628            serde_yaml::Value::Number(8.into()),
629        );
630        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
631        assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
632    }
633
634    #[test]
635    fn scalar_array_option_field_wraps_each_item() {
636        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
637        let mut scalars = BTreeMap::new();
638        scalars.insert(
639            "Components".to_string(),
640            serde_yaml::Value::Sequence(vec![
641                serde_yaml::Value::String("backend".to_string()),
642                serde_yaml::Value::String("auth".to_string()),
643            ]),
644        );
645        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
646        assert_eq!(
647            out.get("customfield_10004").unwrap(),
648            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
649        );
650    }
651
652    #[test]
653    fn scalar_string_array_field_emits_plain_strings() {
654        // Issue #1157: labels (system field, items: string) must go on the
655        // wire as bare strings, not option objects.
656        let editmeta = meta_with_schema(
657            "labels",
658            "Labels",
659            EditMetaSchema {
660                kind: "array".to_string(),
661                items: Some("string".to_string()),
662                system: Some("labels".to_string()),
663                ..EditMetaSchema::default()
664            },
665        );
666        let mut scalars = BTreeMap::new();
667        scalars.insert(
668            "Labels".to_string(),
669            serde_yaml::Value::Sequence(vec![
670                serde_yaml::Value::String("lock-state-v2".to_string()),
671                serde_yaml::Value::String("phase-1".to_string()),
672            ]),
673        );
674        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
675        assert_eq!(
676            out.get("labels").unwrap(),
677            &serde_json::json!(["lock-state-v2", "phase-1"])
678        );
679    }
680
681    #[test]
682    fn scalar_custom_labels_field_emits_plain_strings() {
683        let editmeta = meta_with_schema(
684            "customfield_10050",
685            "Team Tags",
686            EditMetaSchema {
687                kind: "array".to_string(),
688                custom: Some(
689                    "com.atlassian.jira.plugin.system.customfieldtypes:labels".to_string(),
690                ),
691                items: Some("string".to_string()),
692                ..EditMetaSchema::default()
693            },
694        );
695        let mut scalars = BTreeMap::new();
696        scalars.insert(
697            "Team Tags".to_string(),
698            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("infra".to_string())]),
699        );
700        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
701        assert_eq!(
702            out.get("customfield_10050").unwrap(),
703            &serde_json::json!(["infra"])
704        );
705    }
706
707    #[test]
708    fn scalar_component_array_field_wraps_in_name_objects() {
709        let editmeta = meta_with_schema(
710            "components",
711            "Components",
712            EditMetaSchema {
713                kind: "array".to_string(),
714                items: Some("component".to_string()),
715                system: Some("components".to_string()),
716                ..EditMetaSchema::default()
717            },
718        );
719        let mut scalars = BTreeMap::new();
720        scalars.insert(
721            "Components".to_string(),
722            serde_yaml::Value::Sequence(vec![
723                serde_yaml::Value::String("backend".to_string()),
724                serde_yaml::Value::String("auth".to_string()),
725            ]),
726        );
727        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
728        assert_eq!(
729            out.get("components").unwrap(),
730            &serde_json::json!([{"name": "backend"}, {"name": "auth"}])
731        );
732    }
733
734    fn labels_meta() -> EditMeta {
735        meta_with_schema(
736            "labels",
737            "Labels",
738            EditMetaSchema {
739                kind: "array".to_string(),
740                items: Some("string".to_string()),
741                system: Some("labels".to_string()),
742                ..EditMetaSchema::default()
743            },
744        )
745    }
746
747    #[test]
748    fn comma_separated_string_array_field_splits_elements() {
749        // Issue #1172: `Labels=a,b,c` splits into the same payload as
750        // `Labels=[a, b, c]`.
751        let mut scalars = BTreeMap::new();
752        scalars.insert(
753            "Labels".to_string(),
754            serde_yaml::Value::String("a,b,c".to_string()),
755        );
756        let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
757        assert_eq!(
758            out.get("labels").unwrap(),
759            &serde_json::json!(["a", "b", "c"])
760        );
761    }
762
763    #[test]
764    fn comma_separated_option_array_field_wraps_each_value() {
765        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
766        let mut scalars = BTreeMap::new();
767        scalars.insert(
768            "Components".to_string(),
769            serde_yaml::Value::String("backend,auth".to_string()),
770        );
771        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
772        assert_eq!(
773            out.get("customfield_10004").unwrap(),
774            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
775        );
776    }
777
778    #[test]
779    fn comma_separated_component_array_field_wraps_in_name_objects() {
780        let editmeta = meta_with_schema(
781            "components",
782            "Components",
783            EditMetaSchema {
784                kind: "array".to_string(),
785                items: Some("component".to_string()),
786                system: Some("components".to_string()),
787                ..EditMetaSchema::default()
788            },
789        );
790        let mut scalars = BTreeMap::new();
791        scalars.insert(
792            "Components".to_string(),
793            serde_yaml::Value::String("backend,auth".to_string()),
794        );
795        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
796        assert_eq!(
797            out.get("components").unwrap(),
798            &serde_json::json!([{"name": "backend"}, {"name": "auth"}])
799        );
800    }
801
802    #[test]
803    fn comma_split_trims_whitespace_and_drops_empty_elements() {
804        let mut scalars = BTreeMap::new();
805        scalars.insert(
806            "Labels".to_string(),
807            serde_yaml::Value::String(" a , , b ,".to_string()),
808        );
809        let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
810        assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a", "b"]));
811    }
812
813    #[test]
814    fn comma_split_elements_validate_against_allowed_values() {
815        let editmeta = meta_with_allowed("customfield_10004", "Components", "array", &["x", "y"]);
816        let mut scalars = BTreeMap::new();
817        scalars.insert(
818            "Components".to_string(),
819            serde_yaml::Value::String("x,z".to_string()),
820        );
821        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
822        assert!(format!("{err:#}").contains("not an allowed option"));
823    }
824
825    #[test]
826    fn array_field_number_scalar_becomes_single_element() {
827        let mut scalars = BTreeMap::new();
828        scalars.insert("Labels".to_string(), serde_yaml::Value::from(5));
829        let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
830        assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["5"]));
831    }
832
833    #[test]
834    fn array_field_empty_string_sends_empty_array() {
835        // `Labels=""` (and all-comma inputs) drop every empty element and
836        // replace the field with an empty array — i.e. clear it.
837        let mut scalars = BTreeMap::new();
838        scalars.insert(
839            "Labels".to_string(),
840            serde_yaml::Value::String(String::new()),
841        );
842        let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
843        assert_eq!(out.get("labels").unwrap(), &serde_json::json!([]));
844    }
845
846    #[test]
847    fn sequence_element_containing_comma_is_not_split() {
848        // `Labels=["a,b"]` is the escape hatch for a literal comma inside
849        // one element; sequence elements must never be split.
850        let mut scalars = BTreeMap::new();
851        scalars.insert(
852            "Labels".to_string(),
853            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("a,b".to_string())]),
854        );
855        let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
856        assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a,b"]));
857    }
858
859    #[test]
860    fn scalar_issuelink_field_wraps_key() {
861        // Issue #1157: Parent (schema type issuelink) accepts an issue key
862        // string and becomes `{"key": ...}` on the wire.
863        let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
864        let mut scalars = BTreeMap::new();
865        scalars.insert(
866            "Parent".to_string(),
867            serde_yaml::Value::String("PROJ-123".to_string()),
868        );
869        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
870        assert_eq!(
871            out.get("parent").unwrap(),
872            &serde_json::json!({"key": "PROJ-123"})
873        );
874    }
875
876    #[test]
877    fn scalar_issuelink_field_passes_through_mapping() {
878        let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
879        let mut scalars = BTreeMap::new();
880        scalars.insert(
881            "Parent".to_string(),
882            serde_yaml::from_str("id: '10042'").unwrap(),
883        );
884        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
885        assert_eq!(
886            out.get("parent").unwrap(),
887            &serde_json::json!({"id": "10042"})
888        );
889    }
890
891    #[test]
892    fn scalar_issuelink_field_rejects_non_string() {
893        let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
894        let mut scalars = BTreeMap::new();
895        scalars.insert("Parent".to_string(), serde_yaml::Value::Number(7.into()));
896        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
897        assert!(format!("{err:#}").contains("expected an issue key string"));
898    }
899
900    #[test]
901    fn system_field_id_resolves_without_customfield_prefix() {
902        // Issue #1157: `labels` (a system field id) must resolve by id even
903        // though it does not start with `customfield_`.
904        let editmeta = meta_with_schema(
905            "labels",
906            "Labels",
907            EditMetaSchema {
908                kind: "array".to_string(),
909                items: Some("string".to_string()),
910                system: Some("labels".to_string()),
911                ..EditMetaSchema::default()
912            },
913        );
914        let mut scalars = BTreeMap::new();
915        scalars.insert(
916            "labels".to_string(),
917            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("a".to_string())]),
918        );
919        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
920        assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a"]));
921    }
922
923    #[test]
924    fn scalar_string_to_system_description_converts_jfm_to_adf() {
925        let editmeta = meta_with_schema(
926            "description",
927            "Description",
928            EditMetaSchema {
929                kind: "string".to_string(),
930                system: Some("description".to_string()),
931                ..EditMetaSchema::default()
932            },
933        );
934        let mut scalars = BTreeMap::new();
935        scalars.insert(
936            "Description".to_string(),
937            serde_yaml::Value::String("A paragraph.".to_string()),
938        );
939        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
940        let value = out.get("description").unwrap();
941        assert_eq!(value["type"], "doc");
942        assert_eq!(value["version"], 1);
943    }
944
945    #[test]
946    fn rich_text_field_accepts_explicit_adf_mapping() {
947        // Issue #1157: a raw ADF document (mapping with `type: doc`) is
948        // validated and forwarded as-is, bypassing JFM conversion.
949        let editmeta = meta(&[(
950            "customfield_19300",
951            "Acceptance Criteria",
952            "string",
953            Some(CUSTOM_TEXTAREA),
954        )]);
955        let adf_yaml: serde_yaml::Value = serde_yaml::from_str(
956            r"
957type: doc
958version: 1
959content:
960  - type: paragraph
961    content:
962      - type: text
963        text: hand-built
964",
965        )
966        .unwrap();
967        let mut scalars = BTreeMap::new();
968        scalars.insert("Acceptance Criteria".to_string(), adf_yaml);
969        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
970        let value = out.get("customfield_19300").unwrap();
971        assert_eq!(value["type"], "doc");
972        assert_eq!(value["content"][0]["content"][0]["text"], "hand-built");
973    }
974
975    #[test]
976    fn rich_text_field_rejects_mapping_without_doc_type() {
977        let editmeta = meta(&[(
978            "customfield_19300",
979            "Acceptance Criteria",
980            "string",
981            Some(CUSTOM_TEXTAREA),
982        )]);
983        let mut scalars = BTreeMap::new();
984        scalars.insert(
985            "Acceptance Criteria".to_string(),
986            serde_yaml::from_str("some: mapping").unwrap(),
987        );
988        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
989        assert!(format!("{err:#}").contains("must be a raw ADF document with `type: doc`"));
990    }
991
992    #[test]
993    fn rich_text_field_rejects_malformed_adf_document() {
994        // `type: doc` but not deserializable as an ADF document (content
995        // must be a sequence of nodes).
996        let editmeta = meta(&[(
997            "customfield_19300",
998            "Acceptance Criteria",
999            "string",
1000            Some(CUSTOM_TEXTAREA),
1001        )]);
1002        let mut scalars = BTreeMap::new();
1003        scalars.insert(
1004            "Acceptance Criteria".to_string(),
1005            serde_yaml::from_str("{type: doc, version: 1, content: nope}").unwrap(),
1006        );
1007        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1008        assert!(
1009            format!("{err:#}").contains("is not a valid ADF document"),
1010            "got: {err:#}"
1011        );
1012    }
1013
1014    #[test]
1015    fn rich_text_field_rejects_adf_mapping_with_invalid_nesting() {
1016        let editmeta = meta(&[(
1017            "customfield_19300",
1018            "Acceptance Criteria",
1019            "string",
1020            Some(CUSTOM_TEXTAREA),
1021        )]);
1022        let adf_yaml: serde_yaml::Value = serde_yaml::from_str(
1023            r"
1024type: doc
1025version: 1
1026content:
1027  - type: panel
1028    attrs:
1029      panelType: info
1030    content:
1031      - type: expand
1032        attrs:
1033          title: x
1034        content:
1035          - type: paragraph
1036            content:
1037              - type: text
1038                text: body
1039",
1040        )
1041        .unwrap();
1042        let mut scalars = BTreeMap::new();
1043        scalars.insert("Acceptance Criteria".to_string(), adf_yaml);
1044        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1045        let msg = format!("{err:#}");
1046        assert!(msg.contains("ADF nesting validation"), "got: {msg}");
1047    }
1048
1049    #[test]
1050    fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
1051        // Issue #866: a string scalar targeting a textarea custom field is
1052        // treated as JFM markdown and converted to ADF.
1053        let editmeta = meta(&[(
1054            "customfield_19300",
1055            "Acceptance Criteria",
1056            "string",
1057            Some(CUSTOM_TEXTAREA),
1058        )]);
1059        let mut scalars = BTreeMap::new();
1060        scalars.insert(
1061            "Acceptance Criteria".to_string(),
1062            serde_yaml::Value::String("- one\n- two".to_string()),
1063        );
1064        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1065        let value = out.get("customfield_19300").unwrap();
1066        assert_eq!(value["type"], "doc");
1067        assert_eq!(value["version"], 1);
1068        assert!(value["content"].is_array());
1069    }
1070
1071    #[test]
1072    fn scalar_empty_string_to_rich_text_field_clears() {
1073        let editmeta = meta(&[(
1074            "customfield_19300",
1075            "Acceptance Criteria",
1076            "string",
1077            Some(CUSTOM_TEXTAREA),
1078        )]);
1079        let mut scalars = BTreeMap::new();
1080        scalars.insert(
1081            "Acceptance Criteria".to_string(),
1082            serde_yaml::Value::String(String::new()),
1083        );
1084        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1085        assert_eq!(
1086            out.get("customfield_19300").unwrap(),
1087            &serde_json::Value::Null
1088        );
1089    }
1090
1091    #[test]
1092    fn scalar_yaml_null_to_rich_text_field_clears() {
1093        // Distinct from the empty-string case: the CLI's `--set-field Name=`
1094        // parses the empty RHS as YAML null (not a string), so this arm
1095        // covers the production code path callers actually traverse to
1096        // clear a rich-text field from the command line.
1097        let editmeta = meta(&[(
1098            "customfield_19300",
1099            "Acceptance Criteria",
1100            "string",
1101            Some(CUSTOM_TEXTAREA),
1102        )]);
1103        let mut scalars = BTreeMap::new();
1104        scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
1105        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1106        assert_eq!(
1107            out.get("customfield_19300").unwrap(),
1108            &serde_json::Value::Null
1109        );
1110    }
1111
1112    #[test]
1113    fn scalar_non_string_to_rich_text_field_errors() {
1114        // Non-string scalars (numbers, bools, mappings, sequences) targeting
1115        // a rich-text field still need a body section / JFM string.
1116        let editmeta = meta(&[(
1117            "customfield_19300",
1118            "Acceptance Criteria",
1119            "string",
1120            Some(CUSTOM_TEXTAREA),
1121        )]);
1122        let mut scalars = BTreeMap::new();
1123        scalars.insert(
1124            "Acceptance Criteria".to_string(),
1125            serde_yaml::Value::Number(42.into()),
1126        );
1127        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1128        let msg = err.to_string();
1129        assert!(msg.contains("rich-text field"), "got: {msg}");
1130        assert!(msg.contains("JFM markdown"), "got: {msg}");
1131    }
1132
1133    #[test]
1134    fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
1135        let editmeta = meta(&[(
1136            "customfield_19300",
1137            "Acceptance Criteria",
1138            "string",
1139            Some(CUSTOM_TEXTAREA),
1140        )]);
1141        let mut scalars = BTreeMap::new();
1142        scalars.insert(
1143            "Acceptance Criteria".to_string(),
1144            serde_yaml::Value::String(
1145                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1146            ),
1147        );
1148        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1149        let msg = format!("{err:#}");
1150        assert!(msg.contains("Acceptance Criteria"));
1151        assert!(msg.contains("ADF nesting validation"));
1152        assert!(msg.contains("`expand` cannot be a child of `panel`"));
1153    }
1154
1155    #[test]
1156    fn rich_text_section_becomes_adf_payload() {
1157        let editmeta = meta(&[(
1158            "customfield_19300",
1159            "Acceptance Criteria",
1160            "string",
1161            Some(CUSTOM_TEXTAREA),
1162        )]);
1163        let sections = [CustomFieldSection {
1164            name: "Acceptance Criteria".to_string(),
1165            id: "customfield_19300".to_string(),
1166            body: "- Item one\n- Item two".to_string(),
1167        }];
1168        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
1169        let value = out.get("customfield_19300").unwrap();
1170        assert_eq!(value["type"], "doc");
1171        assert_eq!(value["version"], 1);
1172        assert!(value["content"].is_array());
1173    }
1174
1175    #[test]
1176    fn rich_text_section_with_invalid_adf_nesting_errors() {
1177        // Issue #714: a section whose body produces ADF that violates
1178        // Confluence's nesting constraints (here panel→expand) must be
1179        // rejected with the validation context, not silently included in the
1180        // payload.
1181        let editmeta = meta(&[(
1182            "customfield_19300",
1183            "Acceptance Criteria",
1184            "string",
1185            Some(CUSTOM_TEXTAREA),
1186        )]);
1187        let sections = [CustomFieldSection {
1188            name: "Acceptance Criteria".to_string(),
1189            id: "customfield_19300".to_string(),
1190            body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1191        }];
1192        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
1193        let msg = format!("{err:#}");
1194        assert!(msg.contains("Acceptance Criteria"));
1195        assert!(msg.contains("ADF nesting validation"));
1196        assert!(msg.contains("`expand` cannot be a child of `panel`"));
1197    }
1198
1199    #[test]
1200    fn section_pointing_at_non_rich_text_field_errors() {
1201        let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
1202        let sections = [CustomFieldSection {
1203            name: "Priority Flag".to_string(),
1204            id: "customfield_10001".to_string(),
1205            body: "Some text".to_string(),
1206        }];
1207        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
1208        assert!(err.to_string().contains("not a rich-text field"));
1209    }
1210
1211    #[test]
1212    fn unknown_field_name_errors_with_suggestions() {
1213        let editmeta = meta(&[
1214            ("customfield_1", "Alpha", "string", None),
1215            ("customfield_2", "Beta", "string", None),
1216        ]);
1217        let mut scalars = BTreeMap::new();
1218        scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
1219        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1220        let msg = err.to_string();
1221        assert!(msg.contains("Unknown custom field 'Gamma'"));
1222        assert!(msg.contains("Alpha"));
1223        assert!(msg.contains("Beta"));
1224    }
1225
1226    #[test]
1227    fn field_id_bypasses_name_lookup() {
1228        let editmeta = meta(&[(
1229            "customfield_10001",
1230            "Planned / Unplanned Work",
1231            "option",
1232            None,
1233        )]);
1234        let mut scalars = BTreeMap::new();
1235        scalars.insert(
1236            "customfield_10001".to_string(),
1237            serde_yaml::Value::String("Unplanned".to_string()),
1238        );
1239        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1240        assert_eq!(
1241            out.get("customfield_10001").unwrap(),
1242            &serde_json::json!({ "value": "Unplanned" })
1243        );
1244    }
1245
1246    #[test]
1247    fn ambiguous_field_name_errors_listing_ids() {
1248        let editmeta = meta(&[
1249            ("customfield_1", "Duplicate", "string", None),
1250            ("customfield_2", "Duplicate", "string", None),
1251        ]);
1252        let mut scalars = BTreeMap::new();
1253        scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
1254        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1255        let msg = err.to_string();
1256        assert!(msg.contains("Ambiguous"));
1257        assert!(msg.contains("customfield_1"));
1258        assert!(msg.contains("customfield_2"));
1259    }
1260
1261    #[test]
1262    fn array_field_scalar_without_comma_becomes_single_element() {
1263        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
1264        let mut scalars = BTreeMap::new();
1265        scalars.insert(
1266            "Components".to_string(),
1267            serde_yaml::Value::String("solo".to_string()),
1268        );
1269        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1270        assert_eq!(
1271            out.get("customfield_10004").unwrap(),
1272            &serde_json::json!([{"value": "solo"}])
1273        );
1274    }
1275
1276    #[test]
1277    fn array_field_rejects_mapping_value() {
1278        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
1279        let mut scalars = BTreeMap::new();
1280        scalars.insert(
1281            "Components".to_string(),
1282            serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
1283        );
1284        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1285        let msg = format!("{err:#}");
1286        assert!(msg.contains("expected a sequence"));
1287        assert!(msg.contains("comma-separated"));
1288    }
1289
1290    #[test]
1291    fn array_element_must_be_scalar_string() {
1292        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
1293        let mut scalars = BTreeMap::new();
1294        scalars.insert(
1295            "Components".to_string(),
1296            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
1297                serde_yaml::Value::String("nested".to_string()),
1298            ])]),
1299        );
1300        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1301        assert!(format!("{err:#}").contains("expected a scalar string value"));
1302    }
1303
1304    #[test]
1305    fn unsupported_schema_type_errors_with_field_name() {
1306        let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
1307        let mut scalars = BTreeMap::new();
1308        scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
1309        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1310        let msg = format!("{err:#}");
1311        assert!(msg.contains("Unsupported field type 'user'"));
1312        assert!(msg.contains("Reporter"));
1313    }
1314
1315    #[test]
1316    fn option_field_accepts_bool_and_number_scalars() {
1317        let editmeta = meta(&[
1318            (
1319                "customfield_bool",
1320                "Toggle",
1321                "option",
1322                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
1323            ),
1324            (
1325                "customfield_num",
1326                "Number choice",
1327                "option",
1328                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
1329            ),
1330        ]);
1331        let mut scalars = BTreeMap::new();
1332        scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
1333        scalars.insert(
1334            "Number choice".to_string(),
1335            serde_yaml::Value::Number(3.into()),
1336        );
1337        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1338        assert_eq!(
1339            out.get("customfield_bool").unwrap(),
1340            &serde_json::json!({"value": "true"})
1341        );
1342        assert_eq!(
1343            out.get("customfield_num").unwrap(),
1344            &serde_json::json!({"value": "3"})
1345        );
1346    }
1347
1348    #[test]
1349    fn option_value_matching_allowed_values_passes() {
1350        let editmeta = meta_with_allowed(
1351            "customfield_21051",
1352            "Work Attribution",
1353            "option",
1354            &["Planned", "Unplanned"],
1355        );
1356        let mut scalars = BTreeMap::new();
1357        scalars.insert(
1358            "Work Attribution".to_string(),
1359            serde_yaml::Value::String("Planned".to_string()),
1360        );
1361        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1362        assert_eq!(
1363            out.get("customfield_21051").unwrap(),
1364            &serde_json::json!({ "value": "Planned" })
1365        );
1366    }
1367
1368    #[test]
1369    fn option_value_not_in_allowed_values_errors_with_field_and_options() {
1370        let editmeta = meta_with_allowed(
1371            "customfield_21051",
1372            "Work Attribution",
1373            "option",
1374            &["Planned", "Unplanned"],
1375        );
1376        let mut scalars = BTreeMap::new();
1377        scalars.insert(
1378            "Work Attribution".to_string(),
1379            serde_yaml::Value::String("Plnned".to_string()),
1380        );
1381        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1382        let msg = format!("{err:#}");
1383        assert!(msg.contains("Work Attribution"), "got: {msg}");
1384        assert!(msg.contains("customfield_21051"), "got: {msg}");
1385        assert!(
1386            msg.contains("'Plnned' is not an allowed option"),
1387            "got: {msg}"
1388        );
1389        assert!(msg.contains("Planned, Unplanned"), "got: {msg}");
1390    }
1391
1392    #[test]
1393    fn option_value_matching_is_case_sensitive() {
1394        // "planned" != "Planned" — JIRA's option contract is case-sensitive,
1395        // and fuzzy coercion is deliberately avoided.
1396        let editmeta = meta_with_allowed(
1397            "customfield_21051",
1398            "Work Attribution",
1399            "option",
1400            &["Planned"],
1401        );
1402        let mut scalars = BTreeMap::new();
1403        scalars.insert(
1404            "Work Attribution".to_string(),
1405            serde_yaml::Value::String("planned".to_string()),
1406        );
1407        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1408        assert!(format!("{err:#}").contains("not an allowed option"));
1409    }
1410
1411    #[test]
1412    fn option_field_without_allowed_values_passes_any_value_through() {
1413        // No enumerated allowedValues: skip local validation, let the API decide.
1414        let editmeta = meta(&[("customfield_21051", "Work Attribution", "option", None)]);
1415        let mut scalars = BTreeMap::new();
1416        scalars.insert(
1417            "Work Attribution".to_string(),
1418            serde_yaml::Value::String("Anything".to_string()),
1419        );
1420        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
1421        assert_eq!(
1422            out.get("customfield_21051").unwrap(),
1423            &serde_json::json!({ "value": "Anything" })
1424        );
1425    }
1426
1427    #[test]
1428    fn array_option_element_not_in_allowed_values_errors() {
1429        let editmeta =
1430            meta_with_allowed("customfield_10004", "Teams", "array", &["backend", "auth"]);
1431        let mut scalars = BTreeMap::new();
1432        scalars.insert(
1433            "Teams".to_string(),
1434            serde_yaml::Value::Sequence(vec![
1435                serde_yaml::Value::String("backend".to_string()),
1436                serde_yaml::Value::String("frontend".to_string()),
1437            ]),
1438        );
1439        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1440        let msg = format!("{err:#}");
1441        assert!(
1442            msg.contains("'frontend' is not an allowed option"),
1443            "got: {msg}"
1444        );
1445        assert!(msg.contains("backend, auth"), "got: {msg}");
1446    }
1447
1448    #[test]
1449    fn option_field_rejects_non_scalar_value() {
1450        let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
1451        let mut mapping = serde_yaml::Mapping::new();
1452        mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
1453        let mut scalars = BTreeMap::new();
1454        scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
1455        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1456        assert!(format!("{err:#}").contains("expected a scalar string value"));
1457    }
1458
1459    #[test]
1460    fn section_with_stale_id_falls_back_to_name_lookup() {
1461        // editmeta has the field under a new id; the section tag carries an
1462        // older id. Resolver should fall back to name lookup and find it.
1463        let editmeta = meta(&[(
1464            "customfield_NEW",
1465            "Acceptance Criteria",
1466            "string",
1467            Some(CUSTOM_TEXTAREA),
1468        )]);
1469        let sections = [CustomFieldSection {
1470            name: "Acceptance Criteria".to_string(),
1471            id: "customfield_OLD".to_string(),
1472            body: "body".to_string(),
1473        }];
1474        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
1475        assert!(out.contains_key("customfield_NEW"));
1476        assert!(!out.contains_key("customfield_OLD"));
1477    }
1478
1479    #[test]
1480    fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
1481        // A `customfield_<digits>` key that isn't in editmeta should still
1482        // try a name lookup before erroring.
1483        let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
1484        let mut scalars = BTreeMap::new();
1485        scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
1486        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
1487        assert!(err.to_string().contains("Unknown custom field"));
1488    }
1489
1490    // ── parse_set_field / merge_set_field_overrides ─────────────────
1491
1492    #[test]
1493    fn parse_set_field_bare_string_value() {
1494        let (name, value) = parse_set_field("Status=Open").unwrap();
1495        assert_eq!(name, "Status");
1496        assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
1497    }
1498
1499    #[test]
1500    fn parse_set_field_numeric_value_becomes_number() {
1501        let (_name, value) = parse_set_field("Points=8").unwrap();
1502        assert_eq!(value, serde_yaml::Value::Number(8.into()));
1503    }
1504
1505    #[test]
1506    fn parse_set_field_bool_value_becomes_bool() {
1507        let (_name, value) = parse_set_field("Enabled=true").unwrap();
1508        assert_eq!(value, serde_yaml::Value::Bool(true));
1509    }
1510
1511    #[test]
1512    fn parse_set_field_preserves_spaces_in_name() {
1513        let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
1514        assert_eq!(name, "Planned / Unplanned Work");
1515        assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
1516    }
1517
1518    #[test]
1519    fn parse_set_field_equals_in_value_preserved() {
1520        // Only the FIRST `=` splits name from value.
1521        let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
1522        assert_eq!(name, "Formula");
1523        assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
1524    }
1525
1526    #[test]
1527    fn parse_set_field_requires_equals() {
1528        let err = parse_set_field("just-a-name").unwrap_err();
1529        assert!(err.to_string().contains("expected --set-field"));
1530    }
1531
1532    #[test]
1533    fn parse_set_field_empty_name_errors() {
1534        let err = parse_set_field("=value").unwrap_err();
1535        assert!(err.to_string().contains("non-empty name"));
1536    }
1537
1538    #[test]
1539    fn merge_set_field_overrides_cli_wins() {
1540        let mut frontmatter = BTreeMap::new();
1541        frontmatter.insert(
1542            "Priority".to_string(),
1543            serde_yaml::Value::String("Low".to_string()),
1544        );
1545        frontmatter.insert(
1546            "Keep".to_string(),
1547            serde_yaml::Value::String("from-fm".to_string()),
1548        );
1549        let overrides = vec![(
1550            "Priority".to_string(),
1551            serde_yaml::Value::String("High".to_string()),
1552        )];
1553        let merged = merge_set_field_overrides(frontmatter, overrides);
1554        assert_eq!(
1555            merged.get("Priority"),
1556            Some(&serde_yaml::Value::String("High".to_string()))
1557        );
1558        assert_eq!(
1559            merged.get("Keep"),
1560            Some(&serde_yaml::Value::String("from-fm".to_string()))
1561        );
1562    }
1563
1564    #[test]
1565    fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
1566        let mut frontmatter = BTreeMap::new();
1567        frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
1568        let merged = merge_set_field_overrides(frontmatter, vec![]);
1569        assert_eq!(merged.len(), 1);
1570        assert_eq!(
1571            merged.get("K"),
1572            Some(&serde_yaml::Value::String("v".to_string()))
1573        );
1574    }
1575
1576    #[test]
1577    fn section_prefers_tag_id_over_name_lookup() {
1578        // Name "Acceptance Criteria" matches two different IDs globally, but
1579        // the section tag carries a specific ID so no ambiguity error.
1580        let editmeta = meta(&[(
1581            "customfield_19300",
1582            "Acceptance Criteria",
1583            "string",
1584            Some(CUSTOM_TEXTAREA),
1585        )]);
1586        let sections = [CustomFieldSection {
1587            name: "Acceptance Criteria".to_string(),
1588            id: "customfield_19300".to_string(),
1589            body: "body".to_string(),
1590        }];
1591        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
1592        assert!(out.contains_key("customfield_19300"));
1593    }
1594
1595    // ── convert_textarea_string_values ────────────────────────────────
1596
1597    #[test]
1598    fn convert_textarea_string_value_converts_to_adf() {
1599        let editmeta = meta(&[(
1600            "customfield_19300",
1601            "Acceptance Criteria",
1602            "string",
1603            Some(CUSTOM_TEXTAREA),
1604        )]);
1605        let mut fields = BTreeMap::new();
1606        fields.insert(
1607            "customfield_19300".to_string(),
1608            serde_json::Value::String("- one\n- two".to_string()),
1609        );
1610        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1611        let value = fields.get("customfield_19300").unwrap();
1612        assert_eq!(value["type"], "doc");
1613        assert_eq!(value["version"], 1);
1614        assert!(value["content"].is_array());
1615    }
1616
1617    #[test]
1618    fn convert_textarea_object_value_passes_through() {
1619        let editmeta = meta(&[(
1620            "customfield_19300",
1621            "Acceptance Criteria",
1622            "string",
1623            Some(CUSTOM_TEXTAREA),
1624        )]);
1625        let raw_adf = serde_json::json!({
1626            "version": 1,
1627            "type": "doc",
1628            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
1629        });
1630        let mut fields = BTreeMap::new();
1631        fields.insert("customfield_19300".to_string(), raw_adf.clone());
1632        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1633        assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
1634    }
1635
1636    #[test]
1637    fn convert_textarea_empty_string_clears_field() {
1638        let editmeta = meta(&[(
1639            "customfield_19300",
1640            "Acceptance Criteria",
1641            "string",
1642            Some(CUSTOM_TEXTAREA),
1643        )]);
1644        let mut fields = BTreeMap::new();
1645        fields.insert(
1646            "customfield_19300".to_string(),
1647            serde_json::Value::String(String::new()),
1648        );
1649        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1650        assert_eq!(
1651            fields.get("customfield_19300").unwrap(),
1652            &serde_json::Value::Null
1653        );
1654    }
1655
1656    #[test]
1657    fn convert_non_textarea_string_passes_through() {
1658        let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
1659        let mut fields = BTreeMap::new();
1660        fields.insert(
1661            "customfield_10010".to_string(),
1662            serde_json::Value::String("plain".to_string()),
1663        );
1664        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1665        assert_eq!(
1666            fields.get("customfield_10010").unwrap(),
1667            &serde_json::Value::String("plain".to_string())
1668        );
1669    }
1670
1671    #[test]
1672    fn convert_unknown_field_passes_through() {
1673        // Field id not present in editmeta — leave the value alone and let the
1674        // API surface its own error.
1675        let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
1676        let mut fields = BTreeMap::new();
1677        fields.insert(
1678            "customfield_99999".to_string(),
1679            serde_json::Value::String("- a".to_string()),
1680        );
1681        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1682        assert_eq!(
1683            fields.get("customfield_99999").unwrap(),
1684            &serde_json::Value::String("- a".to_string())
1685        );
1686    }
1687
1688    #[test]
1689    fn convert_textarea_non_string_non_object_passes_through() {
1690        // Numbers, bools, arrays, nulls are not coerced — those are not
1691        // legitimate textarea payloads and the API will tell the caller.
1692        let editmeta = meta(&[(
1693            "customfield_19300",
1694            "Acceptance Criteria",
1695            "string",
1696            Some(CUSTOM_TEXTAREA),
1697        )]);
1698        let mut fields = BTreeMap::new();
1699        fields.insert(
1700            "customfield_19300".to_string(),
1701            serde_json::Value::Number(42.into()),
1702        );
1703        convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1704        assert_eq!(
1705            fields.get("customfield_19300").unwrap(),
1706            &serde_json::Value::Number(42.into())
1707        );
1708    }
1709
1710    #[test]
1711    fn convert_textarea_invalid_adf_nesting_errors() {
1712        let editmeta = meta(&[(
1713            "customfield_19300",
1714            "Acceptance Criteria",
1715            "string",
1716            Some(CUSTOM_TEXTAREA),
1717        )]);
1718        let mut fields = BTreeMap::new();
1719        fields.insert(
1720            "customfield_19300".to_string(),
1721            serde_json::Value::String(
1722                ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1723            ),
1724        );
1725        let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
1726        let msg = format!("{err:#}");
1727        assert!(msg.contains("Acceptance Criteria"));
1728        assert!(msg.contains("ADF nesting validation"));
1729        assert!(msg.contains("`expand` cannot be a child of `panel`"));
1730    }
1731}