1use std::collections::BTreeMap;
12
13use anyhow::{anyhow, bail, Context, Result};
14
15use crate::atlassian::adf_validated::ValidatedAdfDocument;
16use crate::atlassian::client::{EditMeta, EditMetaField};
17use crate::atlassian::convert::markdown_to_adf;
18use crate::atlassian::document::CustomFieldSection;
19
20#[cfg(test)]
24const CUSTOM_TEXTAREA: &str = "com.atlassian.jira.plugin.system.customfieldtypes:textarea";
25
26pub 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(§ion.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
90fn 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 }
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
130fn resolve_section_field<'a>(
133 editmeta: &'a EditMeta,
134 section: &CustomFieldSection,
135) -> Result<(String, &'a EditMetaField)> {
136 if let Some(field) = editmeta.fields.get(§ion.id) {
137 return Ok((section.id.clone(), field));
138 }
139 lookup_field(editmeta, §ion.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
146fn 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
202pub 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
220pub 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
235pub 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
264pub 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 #[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 #[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(), §ions, &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 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(), §ions, &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(), §ions, &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 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(), §ions, &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 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 #[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 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 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(), §ions, &editmeta).unwrap();
791 assert!(out.contains_key("customfield_19300"));
792 }
793}