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/// Plugin type URI for the rich-text "textarea" custom field. Used as the
21/// discriminator for dispatch in tests and elsewhere that distinguishes
22/// rich-text custom fields from scalar ones.
23#[cfg(test)]
24const CUSTOM_TEXTAREA: &str = "com.atlassian.jira.plugin.system.customfieldtypes:textarea";
25
26/// Resolves a mixed set of frontmatter scalars and body sections into an
27/// API-ready custom field map keyed by stable field ID.
28///
29/// - **Scalars** are dispatched by schema: option/radiobutton fields become
30///   `{"value": "..."}`, textfield/number/date pass through, rich-text
31///   fields are rejected (must use a body section instead).
32/// - **Sections** must reference rich-text fields; their markdown is
33///   converted to ADF via [`markdown_to_adf`].
34///
35/// Field names are looked up in [`EditMeta`]; entries already formatted as
36/// `customfield_<digits>` bypass the lookup. An unknown or ambiguous name
37/// produces an error naming the available editable fields.
38pub fn resolve_custom_fields(
39    scalars: &BTreeMap<String, serde_yaml::Value>,
40    sections: &[CustomFieldSection],
41    editmeta: &EditMeta,
42) -> Result<BTreeMap<String, serde_json::Value>> {
43    let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
44
45    for (key, value) in scalars {
46        let (id, field) = lookup_field(editmeta, key)?;
47        if field.is_adf_rich_text() {
48            bail!(
49                "Field '{}' ({}) is a rich-text field; set it via a `<!-- field: {} ({}) -->` section in the body, not as a scalar in frontmatter",
50                field.name, id, field.name, id
51            );
52        }
53        let payload = scalar_to_api_value(value, field).with_context(|| {
54            format!(
55                "Failed to convert custom field '{}' ({}) to API value",
56                field.name, id
57            )
58        })?;
59        out.insert(id, payload);
60    }
61
62    for section in sections {
63        let (id, field) = resolve_section_field(editmeta, section)?;
64        if !field.is_adf_rich_text() {
65            bail!(
66                "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
67                field.name, id
68            );
69        }
70        let adf = markdown_to_adf(&section.body).with_context(|| {
71            format!(
72                "Failed to convert body for custom field '{}' ({}) to ADF",
73                field.name, id
74            )
75        })?;
76        let validated = ValidatedAdfDocument::try_new(adf).with_context(|| {
77            format!(
78                "Custom field '{}' ({}) failed ADF nesting validation",
79                field.name, id
80            )
81        })?;
82        let value = serde_json::to_value(&validated)
83            .context("Failed to serialize custom field ADF document")?;
84        out.insert(id, value);
85    }
86
87    Ok(out)
88}
89
90/// Looks up a field by id-or-name, preferring exact `customfield_<id>`
91/// matches before falling back to a name lookup.
92fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
93    if looks_like_field_id(key) {
94        if let Some(field) = editmeta.fields.get(key) {
95            return Ok((key.to_string(), field));
96        }
97        // Fall through to name lookup in case the caller named a field
98        // literally "customfield_something".
99    }
100
101    let matches: Vec<_> = editmeta
102        .fields
103        .iter()
104        .filter(|(_, f)| f.name == key)
105        .collect();
106
107    match matches.as_slice() {
108        [] => {
109            let candidates = editmeta
110                .fields
111                .iter()
112                .map(|(id, f)| format!("  {id}  {}", f.name))
113                .collect::<Vec<_>>()
114                .join("\n");
115            Err(anyhow!(
116                "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
117            ))
118        }
119        [(id, field)] => Ok(((*id).clone(), field)),
120        multi => {
121            let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
122            Err(anyhow!(
123                "Ambiguous custom field '{key}' matches multiple IDs: {}",
124                ids.join(", ")
125            ))
126        }
127    }
128}
129
130/// Resolves a body section's tag (which carries both name and id) against
131/// editmeta, trusting the id when both are present.
132fn resolve_section_field<'a>(
133    editmeta: &'a EditMeta,
134    section: &CustomFieldSection,
135) -> Result<(String, &'a EditMetaField)> {
136    if let Some(field) = editmeta.fields.get(&section.id) {
137        return Ok((section.id.clone(), field));
138    }
139    lookup_field(editmeta, &section.name)
140}
141
142fn looks_like_field_id(s: &str) -> bool {
143    s.starts_with("customfield_") && s[12..].chars().all(|c| c.is_ascii_digit())
144}
145
146/// Dispatches a scalar YAML value to the API shape expected for a given
147/// field schema.
148fn scalar_to_api_value(
149    value: &serde_yaml::Value,
150    field: &EditMetaField,
151) -> Result<serde_json::Value> {
152    let kind = field.schema.kind.as_str();
153    let custom = field.schema.custom.as_deref();
154    match (kind, custom) {
155        ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
156            let s = yaml_as_string(value).with_context(|| {
157                format!("expected a string for option field '{}'", field.name)
158            })?;
159            Ok(serde_json::json!({ "value": s }))
160        }
161        ("array", _) => {
162            let seq = value.as_sequence().ok_or_else(|| {
163                anyhow!("expected a sequence for array field '{}'", field.name)
164            })?;
165            let items: Vec<serde_json::Value> = seq
166                .iter()
167                .map(|v| {
168                    let s = yaml_as_string(v).with_context(|| {
169                        format!(
170                            "expected a string array element for field '{}'",
171                            field.name
172                        )
173                    })?;
174                    Ok(serde_json::json!({ "value": s }))
175                })
176                .collect::<Result<_>>()?;
177            Ok(serde_json::Value::Array(items))
178        }
179        ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
180        (other, _) => Err(anyhow!(
181            "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
182            field.name
183        )),
184    }
185}
186
187fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
188    match value {
189        serde_yaml::Value::String(s) => Ok(s.clone()),
190        serde_yaml::Value::Bool(b) => Ok(b.to_string()),
191        serde_yaml::Value::Number(n) => Ok(n.to_string()),
192        _ => Err(anyhow!("expected a scalar string value")),
193    }
194}
195
196fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
197    let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
198    serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
199        .context("Failed to convert YAML value to JSON")
200}
201
202/// Parses a `--set-field NAME=VALUE` argument into a `(name, value)` pair.
203///
204/// The value is parsed as YAML when possible so `--set-field "Points=8"`
205/// becomes a number and `--set-field "Enabled=true"` becomes a bool.
206/// Values that fail to parse as YAML fall back to plain strings.
207pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
208    let (name, value) = input
209        .split_once('=')
210        .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
211    let name = name.trim().to_string();
212    if name.is_empty() {
213        bail!("--set-field requires a non-empty name before '='");
214    }
215    let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
216        .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
217    Ok((name, yaml_value))
218}
219
220/// Translates an `accountId`-style assignee/reporter input to the JSON
221/// shape JIRA expects.
222///
223/// The empty string clears the user (Atlassian's supported `null` payload);
224/// any other value is wrapped as `{"accountId": "<value>"}`. The literal
225/// `-1` is preserved as `{"accountId": "-1"}`, which JIRA interprets as
226/// automatic assignment.
227pub fn user_field_value(raw: &str) -> serde_json::Value {
228    if raw.is_empty() {
229        serde_json::Value::Null
230    } else {
231        serde_json::json!({ "accountId": raw })
232    }
233}
234
235/// Merges typed `assignee`/`reporter` parameters into a resolved JIRA fields
236/// map.
237///
238/// Rejects collisions where the same field id has already been set
239/// (typically via the `fields` escape hatch on the MCP side or `--set-field`
240/// on the CLI side). `other_source_label` is interpolated into the error
241/// message to identify the colliding source — for example
242/// `the same key inside fields` or ``--set-field`` of the same name``.
243pub fn apply_user_field_overrides(
244    fields: &mut BTreeMap<String, serde_json::Value>,
245    assignee: Option<&str>,
246    reporter: Option<&str>,
247    other_source_label: &str,
248) -> Result<()> {
249    if let Some(value) = assignee {
250        if fields.contains_key("assignee") {
251            bail!("`assignee` collides with {other_source_label}; supply only one");
252        }
253        fields.insert("assignee".to_string(), user_field_value(value));
254    }
255    if let Some(value) = reporter {
256        if fields.contains_key("reporter") {
257            bail!("`reporter` collides with {other_source_label}; supply only one");
258        }
259        fields.insert("reporter".to_string(), user_field_value(value));
260    }
261    Ok(())
262}
263
264/// Merges CLI `--set-field` overrides into a frontmatter scalar map,
265/// with CLI overriding frontmatter on name conflicts.
266pub fn merge_set_field_overrides(
267    frontmatter: BTreeMap<String, serde_yaml::Value>,
268    overrides: Vec<(String, serde_yaml::Value)>,
269) -> BTreeMap<String, serde_yaml::Value> {
270    let mut merged = frontmatter;
271    for (name, value) in overrides {
272        merged.insert(name, value);
273    }
274    merged
275}
276
277#[cfg(test)]
278#[allow(clippy::unwrap_used, clippy::expect_used)]
279mod tests {
280    use super::*;
281    use crate::atlassian::client::{EditMetaField, EditMetaSchema};
282
283    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
284        let mut fields = BTreeMap::new();
285        for (id, name, kind, custom) in entries {
286            fields.insert(
287                (*id).to_string(),
288                EditMetaField {
289                    name: (*name).to_string(),
290                    schema: EditMetaSchema {
291                        kind: (*kind).to_string(),
292                        custom: custom.map(str::to_string),
293                    },
294                },
295            );
296        }
297        EditMeta { fields }
298    }
299
300    // ── user_field_value ──────────────────────────────────────
301
302    #[test]
303    fn user_field_value_empty_string_is_null() {
304        assert_eq!(user_field_value(""), serde_json::Value::Null);
305    }
306
307    #[test]
308    fn user_field_value_account_id_wrapped() {
309        assert_eq!(
310            user_field_value("abc123"),
311            serde_json::json!({"accountId": "abc123"})
312        );
313    }
314
315    #[test]
316    fn user_field_value_dash_one_preserves_auto_assign() {
317        assert_eq!(
318            user_field_value("-1"),
319            serde_json::json!({"accountId": "-1"})
320        );
321    }
322
323    // ── apply_user_field_overrides ────────────────────────────
324
325    #[test]
326    fn apply_user_field_overrides_inserts_assignee_and_reporter() {
327        let mut fields = BTreeMap::new();
328        apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
329        assert_eq!(
330            fields.get("assignee"),
331            Some(&serde_json::json!({"accountId": "a1"}))
332        );
333        assert_eq!(
334            fields.get("reporter"),
335            Some(&serde_json::json!({"accountId": "r1"}))
336        );
337    }
338
339    #[test]
340    fn apply_user_field_overrides_skips_none() {
341        let mut fields = BTreeMap::new();
342        apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
343        assert!(fields.is_empty());
344    }
345
346    #[test]
347    fn apply_user_field_overrides_empty_string_clears() {
348        let mut fields = BTreeMap::new();
349        apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
350        assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
351    }
352
353    #[test]
354    fn apply_user_field_overrides_assignee_collision_errors() {
355        let mut fields = BTreeMap::new();
356        fields.insert(
357            "assignee".to_string(),
358            serde_json::json!({"accountId": "existing"}),
359        );
360        let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
361            .unwrap_err();
362        let msg = err.to_string();
363        assert!(msg.contains("assignee"));
364        assert!(msg.contains("the test source"));
365    }
366
367    #[test]
368    fn apply_user_field_overrides_reporter_collision_errors() {
369        let mut fields = BTreeMap::new();
370        fields.insert(
371            "reporter".to_string(),
372            serde_json::json!({"accountId": "existing"}),
373        );
374        let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
375            .unwrap_err();
376        assert!(err.to_string().contains("reporter"));
377    }
378
379    #[test]
380    fn scalar_option_field_wraps_in_value_object() {
381        let editmeta = meta(&[(
382            "customfield_10001",
383            "Planned / Unplanned Work",
384            "option",
385            Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
386        )]);
387        let mut scalars = BTreeMap::new();
388        scalars.insert(
389            "Planned / Unplanned Work".to_string(),
390            serde_yaml::Value::String("Unplanned".to_string()),
391        );
392        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
393        assert_eq!(
394            out.get("customfield_10001").unwrap(),
395            &serde_json::json!({ "value": "Unplanned" })
396        );
397    }
398
399    #[test]
400    fn scalar_radiobutton_wraps_in_value_object() {
401        let editmeta = meta(&[(
402            "customfield_10002",
403            "Risk",
404            "string",
405            Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
406        )]);
407        let mut scalars = BTreeMap::new();
408        scalars.insert(
409            "Risk".to_string(),
410            serde_yaml::Value::String("High".to_string()),
411        );
412        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
413        assert_eq!(
414            out.get("customfield_10002").unwrap(),
415            &serde_json::json!({ "value": "High" })
416        );
417    }
418
419    #[test]
420    fn scalar_number_field_passes_through() {
421        let editmeta = meta(&[(
422            "customfield_10003",
423            "Story points",
424            "number",
425            Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
426        )]);
427        let mut scalars = BTreeMap::new();
428        scalars.insert(
429            "Story points".to_string(),
430            serde_yaml::Value::Number(8.into()),
431        );
432        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
433        assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
434    }
435
436    #[test]
437    fn scalar_array_option_field_wraps_each_item() {
438        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
439        let mut scalars = BTreeMap::new();
440        scalars.insert(
441            "Components".to_string(),
442            serde_yaml::Value::Sequence(vec![
443                serde_yaml::Value::String("backend".to_string()),
444                serde_yaml::Value::String("auth".to_string()),
445            ]),
446        );
447        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
448        assert_eq!(
449            out.get("customfield_10004").unwrap(),
450            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
451        );
452    }
453
454    #[test]
455    fn scalar_to_rich_text_field_errors() {
456        let editmeta = meta(&[(
457            "customfield_19300",
458            "Acceptance Criteria",
459            "string",
460            Some(CUSTOM_TEXTAREA),
461        )]);
462        let mut scalars = BTreeMap::new();
463        scalars.insert(
464            "Acceptance Criteria".to_string(),
465            serde_yaml::Value::String("just text".to_string()),
466        );
467        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
468        assert!(err.to_string().contains("rich-text field"));
469    }
470
471    #[test]
472    fn rich_text_section_becomes_adf_payload() {
473        let editmeta = meta(&[(
474            "customfield_19300",
475            "Acceptance Criteria",
476            "string",
477            Some(CUSTOM_TEXTAREA),
478        )]);
479        let sections = [CustomFieldSection {
480            name: "Acceptance Criteria".to_string(),
481            id: "customfield_19300".to_string(),
482            body: "- Item one\n- Item two".to_string(),
483        }];
484        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
485        let value = out.get("customfield_19300").unwrap();
486        assert_eq!(value["type"], "doc");
487        assert_eq!(value["version"], 1);
488        assert!(value["content"].is_array());
489    }
490
491    #[test]
492    fn rich_text_section_with_invalid_adf_nesting_errors() {
493        // Issue #714: a section whose body produces ADF that violates
494        // Confluence's nesting constraints (here panel→expand) must be
495        // rejected with the validation context, not silently included in the
496        // payload.
497        let editmeta = meta(&[(
498            "customfield_19300",
499            "Acceptance Criteria",
500            "string",
501            Some(CUSTOM_TEXTAREA),
502        )]);
503        let sections = [CustomFieldSection {
504            name: "Acceptance Criteria".to_string(),
505            id: "customfield_19300".to_string(),
506            body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
507        }];
508        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
509        let msg = format!("{err:#}");
510        assert!(msg.contains("Acceptance Criteria"));
511        assert!(msg.contains("ADF nesting validation"));
512        assert!(msg.contains("`expand` cannot be a child of `panel`"));
513    }
514
515    #[test]
516    fn section_pointing_at_non_rich_text_field_errors() {
517        let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
518        let sections = [CustomFieldSection {
519            name: "Priority Flag".to_string(),
520            id: "customfield_10001".to_string(),
521            body: "Some text".to_string(),
522        }];
523        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
524        assert!(err.to_string().contains("not a rich-text field"));
525    }
526
527    #[test]
528    fn unknown_field_name_errors_with_suggestions() {
529        let editmeta = meta(&[
530            ("customfield_1", "Alpha", "string", None),
531            ("customfield_2", "Beta", "string", None),
532        ]);
533        let mut scalars = BTreeMap::new();
534        scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
535        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
536        let msg = err.to_string();
537        assert!(msg.contains("Unknown custom field 'Gamma'"));
538        assert!(msg.contains("Alpha"));
539        assert!(msg.contains("Beta"));
540    }
541
542    #[test]
543    fn field_id_bypasses_name_lookup() {
544        let editmeta = meta(&[(
545            "customfield_10001",
546            "Planned / Unplanned Work",
547            "option",
548            None,
549        )]);
550        let mut scalars = BTreeMap::new();
551        scalars.insert(
552            "customfield_10001".to_string(),
553            serde_yaml::Value::String("Unplanned".to_string()),
554        );
555        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
556        assert_eq!(
557            out.get("customfield_10001").unwrap(),
558            &serde_json::json!({ "value": "Unplanned" })
559        );
560    }
561
562    #[test]
563    fn ambiguous_field_name_errors_listing_ids() {
564        let editmeta = meta(&[
565            ("customfield_1", "Duplicate", "string", None),
566            ("customfield_2", "Duplicate", "string", None),
567        ]);
568        let mut scalars = BTreeMap::new();
569        scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
570        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
571        let msg = err.to_string();
572        assert!(msg.contains("Ambiguous"));
573        assert!(msg.contains("customfield_1"));
574        assert!(msg.contains("customfield_2"));
575    }
576
577    #[test]
578    fn array_field_requires_sequence_value() {
579        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
580        let mut scalars = BTreeMap::new();
581        scalars.insert(
582            "Components".to_string(),
583            serde_yaml::Value::String("not-a-sequence".to_string()),
584        );
585        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
586        assert!(format!("{err:#}").contains("expected a sequence"));
587    }
588
589    #[test]
590    fn array_element_must_be_scalar_string() {
591        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
592        let mut scalars = BTreeMap::new();
593        scalars.insert(
594            "Components".to_string(),
595            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
596                serde_yaml::Value::String("nested".to_string()),
597            ])]),
598        );
599        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
600        assert!(format!("{err:#}").contains("expected a scalar string value"));
601    }
602
603    #[test]
604    fn unsupported_schema_type_errors_with_field_name() {
605        let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
606        let mut scalars = BTreeMap::new();
607        scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
608        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
609        let msg = format!("{err:#}");
610        assert!(msg.contains("Unsupported field type 'user'"));
611        assert!(msg.contains("Reporter"));
612    }
613
614    #[test]
615    fn option_field_accepts_bool_and_number_scalars() {
616        let editmeta = meta(&[
617            (
618                "customfield_bool",
619                "Toggle",
620                "option",
621                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
622            ),
623            (
624                "customfield_num",
625                "Number choice",
626                "option",
627                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
628            ),
629        ]);
630        let mut scalars = BTreeMap::new();
631        scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
632        scalars.insert(
633            "Number choice".to_string(),
634            serde_yaml::Value::Number(3.into()),
635        );
636        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
637        assert_eq!(
638            out.get("customfield_bool").unwrap(),
639            &serde_json::json!({"value": "true"})
640        );
641        assert_eq!(
642            out.get("customfield_num").unwrap(),
643            &serde_json::json!({"value": "3"})
644        );
645    }
646
647    #[test]
648    fn option_field_rejects_non_scalar_value() {
649        let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
650        let mut mapping = serde_yaml::Mapping::new();
651        mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
652        let mut scalars = BTreeMap::new();
653        scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
654        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
655        assert!(format!("{err:#}").contains("expected a scalar string value"));
656    }
657
658    #[test]
659    fn section_with_stale_id_falls_back_to_name_lookup() {
660        // editmeta has the field under a new id; the section tag carries an
661        // older id. Resolver should fall back to name lookup and find it.
662        let editmeta = meta(&[(
663            "customfield_NEW",
664            "Acceptance Criteria",
665            "string",
666            Some(CUSTOM_TEXTAREA),
667        )]);
668        let sections = [CustomFieldSection {
669            name: "Acceptance Criteria".to_string(),
670            id: "customfield_OLD".to_string(),
671            body: "body".to_string(),
672        }];
673        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
674        assert!(out.contains_key("customfield_NEW"));
675        assert!(!out.contains_key("customfield_OLD"));
676    }
677
678    #[test]
679    fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
680        // A `customfield_<digits>` key that isn't in editmeta should still
681        // try a name lookup before erroring.
682        let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
683        let mut scalars = BTreeMap::new();
684        scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
685        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
686        assert!(err.to_string().contains("Unknown custom field"));
687    }
688
689    // ── parse_set_field / merge_set_field_overrides ─────────────────
690
691    #[test]
692    fn parse_set_field_bare_string_value() {
693        let (name, value) = parse_set_field("Status=Open").unwrap();
694        assert_eq!(name, "Status");
695        assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
696    }
697
698    #[test]
699    fn parse_set_field_numeric_value_becomes_number() {
700        let (_name, value) = parse_set_field("Points=8").unwrap();
701        assert_eq!(value, serde_yaml::Value::Number(8.into()));
702    }
703
704    #[test]
705    fn parse_set_field_bool_value_becomes_bool() {
706        let (_name, value) = parse_set_field("Enabled=true").unwrap();
707        assert_eq!(value, serde_yaml::Value::Bool(true));
708    }
709
710    #[test]
711    fn parse_set_field_preserves_spaces_in_name() {
712        let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
713        assert_eq!(name, "Planned / Unplanned Work");
714        assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
715    }
716
717    #[test]
718    fn parse_set_field_equals_in_value_preserved() {
719        // Only the FIRST `=` splits name from value.
720        let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
721        assert_eq!(name, "Formula");
722        assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
723    }
724
725    #[test]
726    fn parse_set_field_requires_equals() {
727        let err = parse_set_field("just-a-name").unwrap_err();
728        assert!(err.to_string().contains("expected --set-field"));
729    }
730
731    #[test]
732    fn parse_set_field_empty_name_errors() {
733        let err = parse_set_field("=value").unwrap_err();
734        assert!(err.to_string().contains("non-empty name"));
735    }
736
737    #[test]
738    fn merge_set_field_overrides_cli_wins() {
739        let mut frontmatter = BTreeMap::new();
740        frontmatter.insert(
741            "Priority".to_string(),
742            serde_yaml::Value::String("Low".to_string()),
743        );
744        frontmatter.insert(
745            "Keep".to_string(),
746            serde_yaml::Value::String("from-fm".to_string()),
747        );
748        let overrides = vec![(
749            "Priority".to_string(),
750            serde_yaml::Value::String("High".to_string()),
751        )];
752        let merged = merge_set_field_overrides(frontmatter, overrides);
753        assert_eq!(
754            merged.get("Priority"),
755            Some(&serde_yaml::Value::String("High".to_string()))
756        );
757        assert_eq!(
758            merged.get("Keep"),
759            Some(&serde_yaml::Value::String("from-fm".to_string()))
760        );
761    }
762
763    #[test]
764    fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
765        let mut frontmatter = BTreeMap::new();
766        frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
767        let merged = merge_set_field_overrides(frontmatter, vec![]);
768        assert_eq!(merged.len(), 1);
769        assert_eq!(
770            merged.get("K"),
771            Some(&serde_yaml::Value::String("v".to_string()))
772        );
773    }
774
775    #[test]
776    fn section_prefers_tag_id_over_name_lookup() {
777        // Name "Acceptance Criteria" matches two different IDs globally, but
778        // the section tag carries a specific ID so no ambiguity error.
779        let editmeta = meta(&[(
780            "customfield_19300",
781            "Acceptance Criteria",
782            "string",
783            Some(CUSTOM_TEXTAREA),
784        )]);
785        let sections = [CustomFieldSection {
786            name: "Acceptance Criteria".to_string(),
787            id: "customfield_19300".to_string(),
788            body: "body".to_string(),
789        }];
790        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
791        assert!(out.contains_key("customfield_19300"));
792    }
793}