1use 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
23pub 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(§ion.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
82fn 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
120fn resolve_section_field<'a>(
123 editmeta: &'a EditMeta,
124 section: &CustomFieldSection,
125) -> Result<(String, &'a EditMetaField)> {
126 if let Some(field) = editmeta.fields.get(§ion.id) {
127 return Ok((section.id.clone(), field));
128 }
129 lookup_field(editmeta, §ion.name)
130}
131
132fn 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
166fn 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
195fn 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
206pub 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
242fn 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 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 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 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
325fn 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
365pub 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
383pub 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
398pub 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
427pub 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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(), §ions, &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 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(), §ions, &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(), §ions, &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 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 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 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(), §ions, &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 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 #[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 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 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(), §ions, &editmeta).unwrap();
1592 assert!(out.contains_key("customfield_19300"));
1593 }
1594
1595 #[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 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 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}