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)]
21use crate::atlassian::client::TEXTAREA_CUSTOM_TYPE as CUSTOM_TEXTAREA;
22
23pub fn resolve_custom_fields(
36 scalars: &BTreeMap<String, serde_yaml::Value>,
37 sections: &[CustomFieldSection],
38 editmeta: &EditMeta,
39) -> Result<BTreeMap<String, serde_json::Value>> {
40 let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
41
42 for (key, value) in scalars {
43 let (id, field) = lookup_field(editmeta, key)?;
44 if field.is_adf_rich_text() {
45 let payload = rich_text_scalar_to_api_value(value, field, &id)?;
46 out.insert(id, payload);
47 continue;
48 }
49 let payload = scalar_to_api_value(value, field, &id).with_context(|| {
50 format!(
51 "Failed to convert custom field '{}' ({}) to API value",
52 field.name, id
53 )
54 })?;
55 out.insert(id, payload);
56 }
57
58 for section in sections {
59 let (id, field) = resolve_section_field(editmeta, section)?;
60 if !field.is_adf_rich_text() {
61 bail!(
62 "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
63 field.name, id
64 );
65 }
66 let adf = markdown_to_adf(§ion.body).with_context(|| {
67 format!(
68 "Failed to convert body for custom field '{}' ({}) to ADF",
69 field.name, id
70 )
71 })?;
72 let validated = ValidatedAdfDocument::try_new(adf).with_context(|| {
73 format!(
74 "Custom field '{}' ({}) failed ADF nesting validation",
75 field.name, id
76 )
77 })?;
78 let value = serde_json::to_value(&validated)
79 .context("Failed to serialize custom field ADF document")?;
80 out.insert(id, value);
81 }
82
83 Ok(out)
84}
85
86fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
89 if looks_like_field_id(key) {
90 if let Some(field) = editmeta.fields.get(key) {
91 return Ok((key.to_string(), field));
92 }
93 }
96
97 let matches: Vec<_> = editmeta
98 .fields
99 .iter()
100 .filter(|(_, f)| f.name == key)
101 .collect();
102
103 match matches.as_slice() {
104 [] => {
105 let candidates = editmeta
106 .fields
107 .iter()
108 .map(|(id, f)| format!(" {id} {}", f.name))
109 .collect::<Vec<_>>()
110 .join("\n");
111 Err(anyhow!(
112 "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
113 ))
114 }
115 [(id, field)] => Ok(((*id).clone(), field)),
116 multi => {
117 let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
118 Err(anyhow!(
119 "Ambiguous custom field '{key}' matches multiple IDs: {}",
120 ids.join(", ")
121 ))
122 }
123 }
124}
125
126fn resolve_section_field<'a>(
129 editmeta: &'a EditMeta,
130 section: &CustomFieldSection,
131) -> Result<(String, &'a EditMetaField)> {
132 if let Some(field) = editmeta.fields.get(§ion.id) {
133 return Ok((section.id.clone(), field));
134 }
135 lookup_field(editmeta, §ion.name)
136}
137
138fn looks_like_field_id(s: &str) -> bool {
139 s.starts_with("customfield_") && s[12..].chars().all(|c| c.is_ascii_digit())
140}
141
142fn rich_text_scalar_to_api_value(
155 value: &serde_yaml::Value,
156 field: &EditMetaField,
157 id: &str,
158) -> Result<serde_json::Value> {
159 let s = match value {
160 serde_yaml::Value::String(s) => s.clone(),
161 serde_yaml::Value::Null => String::new(),
162 _ => bail!(
163 "Field '{}' ({}) is a rich-text field; supply JFM markdown as a string or use a `<!-- field: {} ({}) -->` body section",
164 field.name,
165 id,
166 field.name,
167 id
168 ),
169 };
170 string_to_rich_text_api_value(&s, &field.name, id)
171}
172
173fn string_to_rich_text_api_value(s: &str, field_name: &str, id: &str) -> Result<serde_json::Value> {
175 if s.is_empty() {
176 return Ok(serde_json::Value::Null);
177 }
178 let adf = markdown_to_adf(s)?;
179 let validated = ValidatedAdfDocument::try_new(adf).with_context(|| {
180 format!("Custom field '{field_name}' ({id}) failed ADF nesting validation")
181 })?;
182 serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
183}
184
185pub fn convert_textarea_string_values(
203 fields: &mut BTreeMap<String, serde_json::Value>,
204 editmeta: &EditMeta,
205) -> Result<()> {
206 for (id, value) in fields.iter_mut() {
207 let Some(field) = editmeta.fields.get(id) else {
208 continue;
209 };
210 if !field.is_adf_rich_text() {
211 continue;
212 }
213 let serde_json::Value::String(s) = value else {
214 continue;
215 };
216 *value = string_to_rich_text_api_value(s, &field.name, id)?;
217 }
218 Ok(())
219}
220
221fn scalar_to_api_value(
224 value: &serde_yaml::Value,
225 field: &EditMetaField,
226 id: &str,
227) -> Result<serde_json::Value> {
228 let kind = field.schema.kind.as_str();
229 let custom = field.schema.custom.as_deref();
230 match (kind, custom) {
231 ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
232 let s = yaml_as_string(value).with_context(|| {
233 format!("expected a string for option field '{}'", field.name)
234 })?;
235 validate_option_value(field, id, &s)?;
236 Ok(serde_json::json!({ "value": s }))
237 }
238 ("array", _) => {
239 let seq = value.as_sequence().ok_or_else(|| {
240 anyhow!("expected a sequence for array field '{}'", field.name)
241 })?;
242 let items: Vec<serde_json::Value> = seq
243 .iter()
244 .map(|v| {
245 let s = yaml_as_string(v).with_context(|| {
246 format!(
247 "expected a string array element for field '{}'",
248 field.name
249 )
250 })?;
251 validate_option_value(field, id, &s)?;
252 Ok(serde_json::json!({ "value": s }))
253 })
254 .collect::<Result<_>>()?;
255 Ok(serde_json::Value::Array(items))
256 }
257 ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
258 (other, _) => Err(anyhow!(
259 "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
260 field.name
261 )),
262 }
263}
264
265fn validate_option_value(field: &EditMetaField, id: &str, value: &str) -> Result<()> {
278 if field.allowed_values.is_empty() || field.allowed_values.iter().any(|v| v == value) {
279 return Ok(());
280 }
281 bail!(
282 "Field '{}' ({}): '{}' is not an allowed option. Valid options: {}",
283 field.name,
284 id,
285 value,
286 field.allowed_values.join(", ")
287 )
288}
289
290fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
291 match value {
292 serde_yaml::Value::String(s) => Ok(s.clone()),
293 serde_yaml::Value::Bool(b) => Ok(b.to_string()),
294 serde_yaml::Value::Number(n) => Ok(n.to_string()),
295 _ => Err(anyhow!("expected a scalar string value")),
296 }
297}
298
299fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
300 let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
301 serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
302 .context("Failed to convert YAML value to JSON")
303}
304
305pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
311 let (name, value) = input
312 .split_once('=')
313 .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
314 let name = name.trim().to_string();
315 if name.is_empty() {
316 bail!("--set-field requires a non-empty name before '='");
317 }
318 let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
319 .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
320 Ok((name, yaml_value))
321}
322
323pub fn user_field_value(raw: &str) -> serde_json::Value {
331 if raw.is_empty() {
332 serde_json::Value::Null
333 } else {
334 serde_json::json!({ "accountId": raw })
335 }
336}
337
338pub fn apply_user_field_overrides(
347 fields: &mut BTreeMap<String, serde_json::Value>,
348 assignee: Option<&str>,
349 reporter: Option<&str>,
350 other_source_label: &str,
351) -> Result<()> {
352 if let Some(value) = assignee {
353 if fields.contains_key("assignee") {
354 bail!("`assignee` collides with {other_source_label}; supply only one");
355 }
356 fields.insert("assignee".to_string(), user_field_value(value));
357 }
358 if let Some(value) = reporter {
359 if fields.contains_key("reporter") {
360 bail!("`reporter` collides with {other_source_label}; supply only one");
361 }
362 fields.insert("reporter".to_string(), user_field_value(value));
363 }
364 Ok(())
365}
366
367pub fn merge_set_field_overrides(
370 frontmatter: BTreeMap<String, serde_yaml::Value>,
371 overrides: Vec<(String, serde_yaml::Value)>,
372) -> BTreeMap<String, serde_yaml::Value> {
373 let mut merged = frontmatter;
374 for (name, value) in overrides {
375 merged.insert(name, value);
376 }
377 merged
378}
379
380#[cfg(test)]
381#[allow(clippy::unwrap_used, clippy::expect_used)]
382mod tests {
383 use super::*;
384 use crate::atlassian::client::{EditMetaField, EditMetaSchema};
385
386 fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
387 let mut fields = BTreeMap::new();
388 for (id, name, kind, custom) in entries {
389 fields.insert(
390 (*id).to_string(),
391 EditMetaField {
392 name: (*name).to_string(),
393 schema: EditMetaSchema {
394 kind: (*kind).to_string(),
395 custom: custom.map(str::to_string),
396 },
397 allowed_values: Vec::new(),
398 },
399 );
400 }
401 EditMeta { fields }
402 }
403
404 fn meta_with_allowed(id: &str, name: &str, kind: &str, allowed: &[&str]) -> EditMeta {
407 let mut fields = BTreeMap::new();
408 fields.insert(
409 id.to_string(),
410 EditMetaField {
411 name: name.to_string(),
412 schema: EditMetaSchema {
413 kind: kind.to_string(),
414 custom: None,
415 },
416 allowed_values: allowed.iter().map(|v| (*v).to_string()).collect(),
417 },
418 );
419 EditMeta { fields }
420 }
421
422 #[test]
425 fn user_field_value_empty_string_is_null() {
426 assert_eq!(user_field_value(""), serde_json::Value::Null);
427 }
428
429 #[test]
430 fn user_field_value_account_id_wrapped() {
431 assert_eq!(
432 user_field_value("abc123"),
433 serde_json::json!({"accountId": "abc123"})
434 );
435 }
436
437 #[test]
438 fn user_field_value_dash_one_preserves_auto_assign() {
439 assert_eq!(
440 user_field_value("-1"),
441 serde_json::json!({"accountId": "-1"})
442 );
443 }
444
445 #[test]
448 fn apply_user_field_overrides_inserts_assignee_and_reporter() {
449 let mut fields = BTreeMap::new();
450 apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
451 assert_eq!(
452 fields.get("assignee"),
453 Some(&serde_json::json!({"accountId": "a1"}))
454 );
455 assert_eq!(
456 fields.get("reporter"),
457 Some(&serde_json::json!({"accountId": "r1"}))
458 );
459 }
460
461 #[test]
462 fn apply_user_field_overrides_skips_none() {
463 let mut fields = BTreeMap::new();
464 apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
465 assert!(fields.is_empty());
466 }
467
468 #[test]
469 fn apply_user_field_overrides_empty_string_clears() {
470 let mut fields = BTreeMap::new();
471 apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
472 assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
473 }
474
475 #[test]
476 fn apply_user_field_overrides_assignee_collision_errors() {
477 let mut fields = BTreeMap::new();
478 fields.insert(
479 "assignee".to_string(),
480 serde_json::json!({"accountId": "existing"}),
481 );
482 let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
483 .unwrap_err();
484 let msg = err.to_string();
485 assert!(msg.contains("assignee"));
486 assert!(msg.contains("the test source"));
487 }
488
489 #[test]
490 fn apply_user_field_overrides_reporter_collision_errors() {
491 let mut fields = BTreeMap::new();
492 fields.insert(
493 "reporter".to_string(),
494 serde_json::json!({"accountId": "existing"}),
495 );
496 let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
497 .unwrap_err();
498 assert!(err.to_string().contains("reporter"));
499 }
500
501 #[test]
502 fn scalar_option_field_wraps_in_value_object() {
503 let editmeta = meta(&[(
504 "customfield_10001",
505 "Planned / Unplanned Work",
506 "option",
507 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
508 )]);
509 let mut scalars = BTreeMap::new();
510 scalars.insert(
511 "Planned / Unplanned Work".to_string(),
512 serde_yaml::Value::String("Unplanned".to_string()),
513 );
514 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
515 assert_eq!(
516 out.get("customfield_10001").unwrap(),
517 &serde_json::json!({ "value": "Unplanned" })
518 );
519 }
520
521 #[test]
522 fn scalar_radiobutton_wraps_in_value_object() {
523 let editmeta = meta(&[(
524 "customfield_10002",
525 "Risk",
526 "string",
527 Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
528 )]);
529 let mut scalars = BTreeMap::new();
530 scalars.insert(
531 "Risk".to_string(),
532 serde_yaml::Value::String("High".to_string()),
533 );
534 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
535 assert_eq!(
536 out.get("customfield_10002").unwrap(),
537 &serde_json::json!({ "value": "High" })
538 );
539 }
540
541 #[test]
542 fn scalar_number_field_passes_through() {
543 let editmeta = meta(&[(
544 "customfield_10003",
545 "Story points",
546 "number",
547 Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
548 )]);
549 let mut scalars = BTreeMap::new();
550 scalars.insert(
551 "Story points".to_string(),
552 serde_yaml::Value::Number(8.into()),
553 );
554 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
555 assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
556 }
557
558 #[test]
559 fn scalar_array_option_field_wraps_each_item() {
560 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
561 let mut scalars = BTreeMap::new();
562 scalars.insert(
563 "Components".to_string(),
564 serde_yaml::Value::Sequence(vec![
565 serde_yaml::Value::String("backend".to_string()),
566 serde_yaml::Value::String("auth".to_string()),
567 ]),
568 );
569 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
570 assert_eq!(
571 out.get("customfield_10004").unwrap(),
572 &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
573 );
574 }
575
576 #[test]
577 fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
578 let editmeta = meta(&[(
581 "customfield_19300",
582 "Acceptance Criteria",
583 "string",
584 Some(CUSTOM_TEXTAREA),
585 )]);
586 let mut scalars = BTreeMap::new();
587 scalars.insert(
588 "Acceptance Criteria".to_string(),
589 serde_yaml::Value::String("- one\n- two".to_string()),
590 );
591 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
592 let value = out.get("customfield_19300").unwrap();
593 assert_eq!(value["type"], "doc");
594 assert_eq!(value["version"], 1);
595 assert!(value["content"].is_array());
596 }
597
598 #[test]
599 fn scalar_empty_string_to_rich_text_field_clears() {
600 let editmeta = meta(&[(
601 "customfield_19300",
602 "Acceptance Criteria",
603 "string",
604 Some(CUSTOM_TEXTAREA),
605 )]);
606 let mut scalars = BTreeMap::new();
607 scalars.insert(
608 "Acceptance Criteria".to_string(),
609 serde_yaml::Value::String(String::new()),
610 );
611 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
612 assert_eq!(
613 out.get("customfield_19300").unwrap(),
614 &serde_json::Value::Null
615 );
616 }
617
618 #[test]
619 fn scalar_yaml_null_to_rich_text_field_clears() {
620 let editmeta = meta(&[(
625 "customfield_19300",
626 "Acceptance Criteria",
627 "string",
628 Some(CUSTOM_TEXTAREA),
629 )]);
630 let mut scalars = BTreeMap::new();
631 scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
632 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
633 assert_eq!(
634 out.get("customfield_19300").unwrap(),
635 &serde_json::Value::Null
636 );
637 }
638
639 #[test]
640 fn scalar_non_string_to_rich_text_field_errors() {
641 let editmeta = meta(&[(
644 "customfield_19300",
645 "Acceptance Criteria",
646 "string",
647 Some(CUSTOM_TEXTAREA),
648 )]);
649 let mut scalars = BTreeMap::new();
650 scalars.insert(
651 "Acceptance Criteria".to_string(),
652 serde_yaml::Value::Number(42.into()),
653 );
654 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
655 let msg = err.to_string();
656 assert!(msg.contains("rich-text field"), "got: {msg}");
657 assert!(msg.contains("JFM markdown"), "got: {msg}");
658 }
659
660 #[test]
661 fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
662 let editmeta = meta(&[(
663 "customfield_19300",
664 "Acceptance Criteria",
665 "string",
666 Some(CUSTOM_TEXTAREA),
667 )]);
668 let mut scalars = BTreeMap::new();
669 scalars.insert(
670 "Acceptance Criteria".to_string(),
671 serde_yaml::Value::String(
672 ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
673 ),
674 );
675 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
676 let msg = format!("{err:#}");
677 assert!(msg.contains("Acceptance Criteria"));
678 assert!(msg.contains("ADF nesting validation"));
679 assert!(msg.contains("`expand` cannot be a child of `panel`"));
680 }
681
682 #[test]
683 fn rich_text_section_becomes_adf_payload() {
684 let editmeta = meta(&[(
685 "customfield_19300",
686 "Acceptance Criteria",
687 "string",
688 Some(CUSTOM_TEXTAREA),
689 )]);
690 let sections = [CustomFieldSection {
691 name: "Acceptance Criteria".to_string(),
692 id: "customfield_19300".to_string(),
693 body: "- Item one\n- Item two".to_string(),
694 }];
695 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
696 let value = out.get("customfield_19300").unwrap();
697 assert_eq!(value["type"], "doc");
698 assert_eq!(value["version"], 1);
699 assert!(value["content"].is_array());
700 }
701
702 #[test]
703 fn rich_text_section_with_invalid_adf_nesting_errors() {
704 let editmeta = meta(&[(
709 "customfield_19300",
710 "Acceptance Criteria",
711 "string",
712 Some(CUSTOM_TEXTAREA),
713 )]);
714 let sections = [CustomFieldSection {
715 name: "Acceptance Criteria".to_string(),
716 id: "customfield_19300".to_string(),
717 body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
718 }];
719 let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
720 let msg = format!("{err:#}");
721 assert!(msg.contains("Acceptance Criteria"));
722 assert!(msg.contains("ADF nesting validation"));
723 assert!(msg.contains("`expand` cannot be a child of `panel`"));
724 }
725
726 #[test]
727 fn section_pointing_at_non_rich_text_field_errors() {
728 let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
729 let sections = [CustomFieldSection {
730 name: "Priority Flag".to_string(),
731 id: "customfield_10001".to_string(),
732 body: "Some text".to_string(),
733 }];
734 let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
735 assert!(err.to_string().contains("not a rich-text field"));
736 }
737
738 #[test]
739 fn unknown_field_name_errors_with_suggestions() {
740 let editmeta = meta(&[
741 ("customfield_1", "Alpha", "string", None),
742 ("customfield_2", "Beta", "string", None),
743 ]);
744 let mut scalars = BTreeMap::new();
745 scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
746 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
747 let msg = err.to_string();
748 assert!(msg.contains("Unknown custom field 'Gamma'"));
749 assert!(msg.contains("Alpha"));
750 assert!(msg.contains("Beta"));
751 }
752
753 #[test]
754 fn field_id_bypasses_name_lookup() {
755 let editmeta = meta(&[(
756 "customfield_10001",
757 "Planned / Unplanned Work",
758 "option",
759 None,
760 )]);
761 let mut scalars = BTreeMap::new();
762 scalars.insert(
763 "customfield_10001".to_string(),
764 serde_yaml::Value::String("Unplanned".to_string()),
765 );
766 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
767 assert_eq!(
768 out.get("customfield_10001").unwrap(),
769 &serde_json::json!({ "value": "Unplanned" })
770 );
771 }
772
773 #[test]
774 fn ambiguous_field_name_errors_listing_ids() {
775 let editmeta = meta(&[
776 ("customfield_1", "Duplicate", "string", None),
777 ("customfield_2", "Duplicate", "string", None),
778 ]);
779 let mut scalars = BTreeMap::new();
780 scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
781 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
782 let msg = err.to_string();
783 assert!(msg.contains("Ambiguous"));
784 assert!(msg.contains("customfield_1"));
785 assert!(msg.contains("customfield_2"));
786 }
787
788 #[test]
789 fn array_field_requires_sequence_value() {
790 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
791 let mut scalars = BTreeMap::new();
792 scalars.insert(
793 "Components".to_string(),
794 serde_yaml::Value::String("not-a-sequence".to_string()),
795 );
796 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
797 assert!(format!("{err:#}").contains("expected a sequence"));
798 }
799
800 #[test]
801 fn array_element_must_be_scalar_string() {
802 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
803 let mut scalars = BTreeMap::new();
804 scalars.insert(
805 "Components".to_string(),
806 serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
807 serde_yaml::Value::String("nested".to_string()),
808 ])]),
809 );
810 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
811 assert!(format!("{err:#}").contains("expected a scalar string value"));
812 }
813
814 #[test]
815 fn unsupported_schema_type_errors_with_field_name() {
816 let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
817 let mut scalars = BTreeMap::new();
818 scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
819 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
820 let msg = format!("{err:#}");
821 assert!(msg.contains("Unsupported field type 'user'"));
822 assert!(msg.contains("Reporter"));
823 }
824
825 #[test]
826 fn option_field_accepts_bool_and_number_scalars() {
827 let editmeta = meta(&[
828 (
829 "customfield_bool",
830 "Toggle",
831 "option",
832 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
833 ),
834 (
835 "customfield_num",
836 "Number choice",
837 "option",
838 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
839 ),
840 ]);
841 let mut scalars = BTreeMap::new();
842 scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
843 scalars.insert(
844 "Number choice".to_string(),
845 serde_yaml::Value::Number(3.into()),
846 );
847 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
848 assert_eq!(
849 out.get("customfield_bool").unwrap(),
850 &serde_json::json!({"value": "true"})
851 );
852 assert_eq!(
853 out.get("customfield_num").unwrap(),
854 &serde_json::json!({"value": "3"})
855 );
856 }
857
858 #[test]
859 fn option_value_matching_allowed_values_passes() {
860 let editmeta = meta_with_allowed(
861 "customfield_21051",
862 "Work Attribution",
863 "option",
864 &["Planned", "Unplanned"],
865 );
866 let mut scalars = BTreeMap::new();
867 scalars.insert(
868 "Work Attribution".to_string(),
869 serde_yaml::Value::String("Planned".to_string()),
870 );
871 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
872 assert_eq!(
873 out.get("customfield_21051").unwrap(),
874 &serde_json::json!({ "value": "Planned" })
875 );
876 }
877
878 #[test]
879 fn option_value_not_in_allowed_values_errors_with_field_and_options() {
880 let editmeta = meta_with_allowed(
881 "customfield_21051",
882 "Work Attribution",
883 "option",
884 &["Planned", "Unplanned"],
885 );
886 let mut scalars = BTreeMap::new();
887 scalars.insert(
888 "Work Attribution".to_string(),
889 serde_yaml::Value::String("Plnned".to_string()),
890 );
891 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
892 let msg = format!("{err:#}");
893 assert!(msg.contains("Work Attribution"), "got: {msg}");
894 assert!(msg.contains("customfield_21051"), "got: {msg}");
895 assert!(
896 msg.contains("'Plnned' is not an allowed option"),
897 "got: {msg}"
898 );
899 assert!(msg.contains("Planned, Unplanned"), "got: {msg}");
900 }
901
902 #[test]
903 fn option_value_matching_is_case_sensitive() {
904 let editmeta = meta_with_allowed(
907 "customfield_21051",
908 "Work Attribution",
909 "option",
910 &["Planned"],
911 );
912 let mut scalars = BTreeMap::new();
913 scalars.insert(
914 "Work Attribution".to_string(),
915 serde_yaml::Value::String("planned".to_string()),
916 );
917 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
918 assert!(format!("{err:#}").contains("not an allowed option"));
919 }
920
921 #[test]
922 fn option_field_without_allowed_values_passes_any_value_through() {
923 let editmeta = meta(&[("customfield_21051", "Work Attribution", "option", None)]);
925 let mut scalars = BTreeMap::new();
926 scalars.insert(
927 "Work Attribution".to_string(),
928 serde_yaml::Value::String("Anything".to_string()),
929 );
930 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
931 assert_eq!(
932 out.get("customfield_21051").unwrap(),
933 &serde_json::json!({ "value": "Anything" })
934 );
935 }
936
937 #[test]
938 fn array_option_element_not_in_allowed_values_errors() {
939 let editmeta =
940 meta_with_allowed("customfield_10004", "Teams", "array", &["backend", "auth"]);
941 let mut scalars = BTreeMap::new();
942 scalars.insert(
943 "Teams".to_string(),
944 serde_yaml::Value::Sequence(vec![
945 serde_yaml::Value::String("backend".to_string()),
946 serde_yaml::Value::String("frontend".to_string()),
947 ]),
948 );
949 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
950 let msg = format!("{err:#}");
951 assert!(
952 msg.contains("'frontend' is not an allowed option"),
953 "got: {msg}"
954 );
955 assert!(msg.contains("backend, auth"), "got: {msg}");
956 }
957
958 #[test]
959 fn option_field_rejects_non_scalar_value() {
960 let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
961 let mut mapping = serde_yaml::Mapping::new();
962 mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
963 let mut scalars = BTreeMap::new();
964 scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
965 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
966 assert!(format!("{err:#}").contains("expected a scalar string value"));
967 }
968
969 #[test]
970 fn section_with_stale_id_falls_back_to_name_lookup() {
971 let editmeta = meta(&[(
974 "customfield_NEW",
975 "Acceptance Criteria",
976 "string",
977 Some(CUSTOM_TEXTAREA),
978 )]);
979 let sections = [CustomFieldSection {
980 name: "Acceptance Criteria".to_string(),
981 id: "customfield_OLD".to_string(),
982 body: "body".to_string(),
983 }];
984 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
985 assert!(out.contains_key("customfield_NEW"));
986 assert!(!out.contains_key("customfield_OLD"));
987 }
988
989 #[test]
990 fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
991 let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
994 let mut scalars = BTreeMap::new();
995 scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
996 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
997 assert!(err.to_string().contains("Unknown custom field"));
998 }
999
1000 #[test]
1003 fn parse_set_field_bare_string_value() {
1004 let (name, value) = parse_set_field("Status=Open").unwrap();
1005 assert_eq!(name, "Status");
1006 assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
1007 }
1008
1009 #[test]
1010 fn parse_set_field_numeric_value_becomes_number() {
1011 let (_name, value) = parse_set_field("Points=8").unwrap();
1012 assert_eq!(value, serde_yaml::Value::Number(8.into()));
1013 }
1014
1015 #[test]
1016 fn parse_set_field_bool_value_becomes_bool() {
1017 let (_name, value) = parse_set_field("Enabled=true").unwrap();
1018 assert_eq!(value, serde_yaml::Value::Bool(true));
1019 }
1020
1021 #[test]
1022 fn parse_set_field_preserves_spaces_in_name() {
1023 let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
1024 assert_eq!(name, "Planned / Unplanned Work");
1025 assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
1026 }
1027
1028 #[test]
1029 fn parse_set_field_equals_in_value_preserved() {
1030 let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
1032 assert_eq!(name, "Formula");
1033 assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
1034 }
1035
1036 #[test]
1037 fn parse_set_field_requires_equals() {
1038 let err = parse_set_field("just-a-name").unwrap_err();
1039 assert!(err.to_string().contains("expected --set-field"));
1040 }
1041
1042 #[test]
1043 fn parse_set_field_empty_name_errors() {
1044 let err = parse_set_field("=value").unwrap_err();
1045 assert!(err.to_string().contains("non-empty name"));
1046 }
1047
1048 #[test]
1049 fn merge_set_field_overrides_cli_wins() {
1050 let mut frontmatter = BTreeMap::new();
1051 frontmatter.insert(
1052 "Priority".to_string(),
1053 serde_yaml::Value::String("Low".to_string()),
1054 );
1055 frontmatter.insert(
1056 "Keep".to_string(),
1057 serde_yaml::Value::String("from-fm".to_string()),
1058 );
1059 let overrides = vec![(
1060 "Priority".to_string(),
1061 serde_yaml::Value::String("High".to_string()),
1062 )];
1063 let merged = merge_set_field_overrides(frontmatter, overrides);
1064 assert_eq!(
1065 merged.get("Priority"),
1066 Some(&serde_yaml::Value::String("High".to_string()))
1067 );
1068 assert_eq!(
1069 merged.get("Keep"),
1070 Some(&serde_yaml::Value::String("from-fm".to_string()))
1071 );
1072 }
1073
1074 #[test]
1075 fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
1076 let mut frontmatter = BTreeMap::new();
1077 frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
1078 let merged = merge_set_field_overrides(frontmatter, vec![]);
1079 assert_eq!(merged.len(), 1);
1080 assert_eq!(
1081 merged.get("K"),
1082 Some(&serde_yaml::Value::String("v".to_string()))
1083 );
1084 }
1085
1086 #[test]
1087 fn section_prefers_tag_id_over_name_lookup() {
1088 let editmeta = meta(&[(
1091 "customfield_19300",
1092 "Acceptance Criteria",
1093 "string",
1094 Some(CUSTOM_TEXTAREA),
1095 )]);
1096 let sections = [CustomFieldSection {
1097 name: "Acceptance Criteria".to_string(),
1098 id: "customfield_19300".to_string(),
1099 body: "body".to_string(),
1100 }];
1101 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
1102 assert!(out.contains_key("customfield_19300"));
1103 }
1104
1105 #[test]
1108 fn convert_textarea_string_value_converts_to_adf() {
1109 let editmeta = meta(&[(
1110 "customfield_19300",
1111 "Acceptance Criteria",
1112 "string",
1113 Some(CUSTOM_TEXTAREA),
1114 )]);
1115 let mut fields = BTreeMap::new();
1116 fields.insert(
1117 "customfield_19300".to_string(),
1118 serde_json::Value::String("- one\n- two".to_string()),
1119 );
1120 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1121 let value = fields.get("customfield_19300").unwrap();
1122 assert_eq!(value["type"], "doc");
1123 assert_eq!(value["version"], 1);
1124 assert!(value["content"].is_array());
1125 }
1126
1127 #[test]
1128 fn convert_textarea_object_value_passes_through() {
1129 let editmeta = meta(&[(
1130 "customfield_19300",
1131 "Acceptance Criteria",
1132 "string",
1133 Some(CUSTOM_TEXTAREA),
1134 )]);
1135 let raw_adf = serde_json::json!({
1136 "version": 1,
1137 "type": "doc",
1138 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
1139 });
1140 let mut fields = BTreeMap::new();
1141 fields.insert("customfield_19300".to_string(), raw_adf.clone());
1142 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1143 assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
1144 }
1145
1146 #[test]
1147 fn convert_textarea_empty_string_clears_field() {
1148 let editmeta = meta(&[(
1149 "customfield_19300",
1150 "Acceptance Criteria",
1151 "string",
1152 Some(CUSTOM_TEXTAREA),
1153 )]);
1154 let mut fields = BTreeMap::new();
1155 fields.insert(
1156 "customfield_19300".to_string(),
1157 serde_json::Value::String(String::new()),
1158 );
1159 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1160 assert_eq!(
1161 fields.get("customfield_19300").unwrap(),
1162 &serde_json::Value::Null
1163 );
1164 }
1165
1166 #[test]
1167 fn convert_non_textarea_string_passes_through() {
1168 let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
1169 let mut fields = BTreeMap::new();
1170 fields.insert(
1171 "customfield_10010".to_string(),
1172 serde_json::Value::String("plain".to_string()),
1173 );
1174 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1175 assert_eq!(
1176 fields.get("customfield_10010").unwrap(),
1177 &serde_json::Value::String("plain".to_string())
1178 );
1179 }
1180
1181 #[test]
1182 fn convert_unknown_field_passes_through() {
1183 let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
1186 let mut fields = BTreeMap::new();
1187 fields.insert(
1188 "customfield_99999".to_string(),
1189 serde_json::Value::String("- a".to_string()),
1190 );
1191 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1192 assert_eq!(
1193 fields.get("customfield_99999").unwrap(),
1194 &serde_json::Value::String("- a".to_string())
1195 );
1196 }
1197
1198 #[test]
1199 fn convert_textarea_non_string_non_object_passes_through() {
1200 let editmeta = meta(&[(
1203 "customfield_19300",
1204 "Acceptance Criteria",
1205 "string",
1206 Some(CUSTOM_TEXTAREA),
1207 )]);
1208 let mut fields = BTreeMap::new();
1209 fields.insert(
1210 "customfield_19300".to_string(),
1211 serde_json::Value::Number(42.into()),
1212 );
1213 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1214 assert_eq!(
1215 fields.get("customfield_19300").unwrap(),
1216 &serde_json::Value::Number(42.into())
1217 );
1218 }
1219
1220 #[test]
1221 fn convert_textarea_invalid_adf_nesting_errors() {
1222 let editmeta = meta(&[(
1223 "customfield_19300",
1224 "Acceptance Criteria",
1225 "string",
1226 Some(CUSTOM_TEXTAREA),
1227 )]);
1228 let mut fields = BTreeMap::new();
1229 fields.insert(
1230 "customfield_19300".to_string(),
1231 serde_json::Value::String(
1232 ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1233 ),
1234 );
1235 let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
1236 let msg = format!("{err:#}");
1237 assert!(msg.contains("Acceptance Criteria"));
1238 assert!(msg.contains("ADF nesting validation"));
1239 assert!(msg.contains("`expand` cannot be a child of `panel`"));
1240 }
1241}