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).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) -> Result<serde_json::Value> {
227 let kind = field.schema.kind.as_str();
228 let custom = field.schema.custom.as_deref();
229 match (kind, custom) {
230 ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
231 let s = yaml_as_string(value).with_context(|| {
232 format!("expected a string for option field '{}'", field.name)
233 })?;
234 Ok(serde_json::json!({ "value": s }))
235 }
236 ("array", _) => {
237 let seq = value.as_sequence().ok_or_else(|| {
238 anyhow!("expected a sequence for array field '{}'", field.name)
239 })?;
240 let items: Vec<serde_json::Value> = seq
241 .iter()
242 .map(|v| {
243 let s = yaml_as_string(v).with_context(|| {
244 format!(
245 "expected a string array element for field '{}'",
246 field.name
247 )
248 })?;
249 Ok(serde_json::json!({ "value": s }))
250 })
251 .collect::<Result<_>>()?;
252 Ok(serde_json::Value::Array(items))
253 }
254 ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
255 (other, _) => Err(anyhow!(
256 "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
257 field.name
258 )),
259 }
260}
261
262fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
263 match value {
264 serde_yaml::Value::String(s) => Ok(s.clone()),
265 serde_yaml::Value::Bool(b) => Ok(b.to_string()),
266 serde_yaml::Value::Number(n) => Ok(n.to_string()),
267 _ => Err(anyhow!("expected a scalar string value")),
268 }
269}
270
271fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
272 let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
273 serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
274 .context("Failed to convert YAML value to JSON")
275}
276
277pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
283 let (name, value) = input
284 .split_once('=')
285 .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
286 let name = name.trim().to_string();
287 if name.is_empty() {
288 bail!("--set-field requires a non-empty name before '='");
289 }
290 let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
291 .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
292 Ok((name, yaml_value))
293}
294
295pub fn user_field_value(raw: &str) -> serde_json::Value {
303 if raw.is_empty() {
304 serde_json::Value::Null
305 } else {
306 serde_json::json!({ "accountId": raw })
307 }
308}
309
310pub fn apply_user_field_overrides(
319 fields: &mut BTreeMap<String, serde_json::Value>,
320 assignee: Option<&str>,
321 reporter: Option<&str>,
322 other_source_label: &str,
323) -> Result<()> {
324 if let Some(value) = assignee {
325 if fields.contains_key("assignee") {
326 bail!("`assignee` collides with {other_source_label}; supply only one");
327 }
328 fields.insert("assignee".to_string(), user_field_value(value));
329 }
330 if let Some(value) = reporter {
331 if fields.contains_key("reporter") {
332 bail!("`reporter` collides with {other_source_label}; supply only one");
333 }
334 fields.insert("reporter".to_string(), user_field_value(value));
335 }
336 Ok(())
337}
338
339pub fn merge_set_field_overrides(
342 frontmatter: BTreeMap<String, serde_yaml::Value>,
343 overrides: Vec<(String, serde_yaml::Value)>,
344) -> BTreeMap<String, serde_yaml::Value> {
345 let mut merged = frontmatter;
346 for (name, value) in overrides {
347 merged.insert(name, value);
348 }
349 merged
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355 use super::*;
356 use crate::atlassian::client::{EditMetaField, EditMetaSchema};
357
358 fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
359 let mut fields = BTreeMap::new();
360 for (id, name, kind, custom) in entries {
361 fields.insert(
362 (*id).to_string(),
363 EditMetaField {
364 name: (*name).to_string(),
365 schema: EditMetaSchema {
366 kind: (*kind).to_string(),
367 custom: custom.map(str::to_string),
368 },
369 },
370 );
371 }
372 EditMeta { fields }
373 }
374
375 #[test]
378 fn user_field_value_empty_string_is_null() {
379 assert_eq!(user_field_value(""), serde_json::Value::Null);
380 }
381
382 #[test]
383 fn user_field_value_account_id_wrapped() {
384 assert_eq!(
385 user_field_value("abc123"),
386 serde_json::json!({"accountId": "abc123"})
387 );
388 }
389
390 #[test]
391 fn user_field_value_dash_one_preserves_auto_assign() {
392 assert_eq!(
393 user_field_value("-1"),
394 serde_json::json!({"accountId": "-1"})
395 );
396 }
397
398 #[test]
401 fn apply_user_field_overrides_inserts_assignee_and_reporter() {
402 let mut fields = BTreeMap::new();
403 apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
404 assert_eq!(
405 fields.get("assignee"),
406 Some(&serde_json::json!({"accountId": "a1"}))
407 );
408 assert_eq!(
409 fields.get("reporter"),
410 Some(&serde_json::json!({"accountId": "r1"}))
411 );
412 }
413
414 #[test]
415 fn apply_user_field_overrides_skips_none() {
416 let mut fields = BTreeMap::new();
417 apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
418 assert!(fields.is_empty());
419 }
420
421 #[test]
422 fn apply_user_field_overrides_empty_string_clears() {
423 let mut fields = BTreeMap::new();
424 apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
425 assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
426 }
427
428 #[test]
429 fn apply_user_field_overrides_assignee_collision_errors() {
430 let mut fields = BTreeMap::new();
431 fields.insert(
432 "assignee".to_string(),
433 serde_json::json!({"accountId": "existing"}),
434 );
435 let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
436 .unwrap_err();
437 let msg = err.to_string();
438 assert!(msg.contains("assignee"));
439 assert!(msg.contains("the test source"));
440 }
441
442 #[test]
443 fn apply_user_field_overrides_reporter_collision_errors() {
444 let mut fields = BTreeMap::new();
445 fields.insert(
446 "reporter".to_string(),
447 serde_json::json!({"accountId": "existing"}),
448 );
449 let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
450 .unwrap_err();
451 assert!(err.to_string().contains("reporter"));
452 }
453
454 #[test]
455 fn scalar_option_field_wraps_in_value_object() {
456 let editmeta = meta(&[(
457 "customfield_10001",
458 "Planned / Unplanned Work",
459 "option",
460 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
461 )]);
462 let mut scalars = BTreeMap::new();
463 scalars.insert(
464 "Planned / Unplanned Work".to_string(),
465 serde_yaml::Value::String("Unplanned".to_string()),
466 );
467 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
468 assert_eq!(
469 out.get("customfield_10001").unwrap(),
470 &serde_json::json!({ "value": "Unplanned" })
471 );
472 }
473
474 #[test]
475 fn scalar_radiobutton_wraps_in_value_object() {
476 let editmeta = meta(&[(
477 "customfield_10002",
478 "Risk",
479 "string",
480 Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
481 )]);
482 let mut scalars = BTreeMap::new();
483 scalars.insert(
484 "Risk".to_string(),
485 serde_yaml::Value::String("High".to_string()),
486 );
487 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
488 assert_eq!(
489 out.get("customfield_10002").unwrap(),
490 &serde_json::json!({ "value": "High" })
491 );
492 }
493
494 #[test]
495 fn scalar_number_field_passes_through() {
496 let editmeta = meta(&[(
497 "customfield_10003",
498 "Story points",
499 "number",
500 Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
501 )]);
502 let mut scalars = BTreeMap::new();
503 scalars.insert(
504 "Story points".to_string(),
505 serde_yaml::Value::Number(8.into()),
506 );
507 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
508 assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
509 }
510
511 #[test]
512 fn scalar_array_option_field_wraps_each_item() {
513 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
514 let mut scalars = BTreeMap::new();
515 scalars.insert(
516 "Components".to_string(),
517 serde_yaml::Value::Sequence(vec![
518 serde_yaml::Value::String("backend".to_string()),
519 serde_yaml::Value::String("auth".to_string()),
520 ]),
521 );
522 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
523 assert_eq!(
524 out.get("customfield_10004").unwrap(),
525 &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
526 );
527 }
528
529 #[test]
530 fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
531 let editmeta = meta(&[(
534 "customfield_19300",
535 "Acceptance Criteria",
536 "string",
537 Some(CUSTOM_TEXTAREA),
538 )]);
539 let mut scalars = BTreeMap::new();
540 scalars.insert(
541 "Acceptance Criteria".to_string(),
542 serde_yaml::Value::String("- one\n- two".to_string()),
543 );
544 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
545 let value = out.get("customfield_19300").unwrap();
546 assert_eq!(value["type"], "doc");
547 assert_eq!(value["version"], 1);
548 assert!(value["content"].is_array());
549 }
550
551 #[test]
552 fn scalar_empty_string_to_rich_text_field_clears() {
553 let editmeta = meta(&[(
554 "customfield_19300",
555 "Acceptance Criteria",
556 "string",
557 Some(CUSTOM_TEXTAREA),
558 )]);
559 let mut scalars = BTreeMap::new();
560 scalars.insert(
561 "Acceptance Criteria".to_string(),
562 serde_yaml::Value::String(String::new()),
563 );
564 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
565 assert_eq!(
566 out.get("customfield_19300").unwrap(),
567 &serde_json::Value::Null
568 );
569 }
570
571 #[test]
572 fn scalar_yaml_null_to_rich_text_field_clears() {
573 let editmeta = meta(&[(
578 "customfield_19300",
579 "Acceptance Criteria",
580 "string",
581 Some(CUSTOM_TEXTAREA),
582 )]);
583 let mut scalars = BTreeMap::new();
584 scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
585 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
586 assert_eq!(
587 out.get("customfield_19300").unwrap(),
588 &serde_json::Value::Null
589 );
590 }
591
592 #[test]
593 fn scalar_non_string_to_rich_text_field_errors() {
594 let editmeta = meta(&[(
597 "customfield_19300",
598 "Acceptance Criteria",
599 "string",
600 Some(CUSTOM_TEXTAREA),
601 )]);
602 let mut scalars = BTreeMap::new();
603 scalars.insert(
604 "Acceptance Criteria".to_string(),
605 serde_yaml::Value::Number(42.into()),
606 );
607 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
608 let msg = err.to_string();
609 assert!(msg.contains("rich-text field"), "got: {msg}");
610 assert!(msg.contains("JFM markdown"), "got: {msg}");
611 }
612
613 #[test]
614 fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
615 let editmeta = meta(&[(
616 "customfield_19300",
617 "Acceptance Criteria",
618 "string",
619 Some(CUSTOM_TEXTAREA),
620 )]);
621 let mut scalars = BTreeMap::new();
622 scalars.insert(
623 "Acceptance Criteria".to_string(),
624 serde_yaml::Value::String(
625 ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
626 ),
627 );
628 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
629 let msg = format!("{err:#}");
630 assert!(msg.contains("Acceptance Criteria"));
631 assert!(msg.contains("ADF nesting validation"));
632 assert!(msg.contains("`expand` cannot be a child of `panel`"));
633 }
634
635 #[test]
636 fn rich_text_section_becomes_adf_payload() {
637 let editmeta = meta(&[(
638 "customfield_19300",
639 "Acceptance Criteria",
640 "string",
641 Some(CUSTOM_TEXTAREA),
642 )]);
643 let sections = [CustomFieldSection {
644 name: "Acceptance Criteria".to_string(),
645 id: "customfield_19300".to_string(),
646 body: "- Item one\n- Item two".to_string(),
647 }];
648 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
649 let value = out.get("customfield_19300").unwrap();
650 assert_eq!(value["type"], "doc");
651 assert_eq!(value["version"], 1);
652 assert!(value["content"].is_array());
653 }
654
655 #[test]
656 fn rich_text_section_with_invalid_adf_nesting_errors() {
657 let editmeta = meta(&[(
662 "customfield_19300",
663 "Acceptance Criteria",
664 "string",
665 Some(CUSTOM_TEXTAREA),
666 )]);
667 let sections = [CustomFieldSection {
668 name: "Acceptance Criteria".to_string(),
669 id: "customfield_19300".to_string(),
670 body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
671 }];
672 let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
673 let msg = format!("{err:#}");
674 assert!(msg.contains("Acceptance Criteria"));
675 assert!(msg.contains("ADF nesting validation"));
676 assert!(msg.contains("`expand` cannot be a child of `panel`"));
677 }
678
679 #[test]
680 fn section_pointing_at_non_rich_text_field_errors() {
681 let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
682 let sections = [CustomFieldSection {
683 name: "Priority Flag".to_string(),
684 id: "customfield_10001".to_string(),
685 body: "Some text".to_string(),
686 }];
687 let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
688 assert!(err.to_string().contains("not a rich-text field"));
689 }
690
691 #[test]
692 fn unknown_field_name_errors_with_suggestions() {
693 let editmeta = meta(&[
694 ("customfield_1", "Alpha", "string", None),
695 ("customfield_2", "Beta", "string", None),
696 ]);
697 let mut scalars = BTreeMap::new();
698 scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
699 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
700 let msg = err.to_string();
701 assert!(msg.contains("Unknown custom field 'Gamma'"));
702 assert!(msg.contains("Alpha"));
703 assert!(msg.contains("Beta"));
704 }
705
706 #[test]
707 fn field_id_bypasses_name_lookup() {
708 let editmeta = meta(&[(
709 "customfield_10001",
710 "Planned / Unplanned Work",
711 "option",
712 None,
713 )]);
714 let mut scalars = BTreeMap::new();
715 scalars.insert(
716 "customfield_10001".to_string(),
717 serde_yaml::Value::String("Unplanned".to_string()),
718 );
719 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
720 assert_eq!(
721 out.get("customfield_10001").unwrap(),
722 &serde_json::json!({ "value": "Unplanned" })
723 );
724 }
725
726 #[test]
727 fn ambiguous_field_name_errors_listing_ids() {
728 let editmeta = meta(&[
729 ("customfield_1", "Duplicate", "string", None),
730 ("customfield_2", "Duplicate", "string", None),
731 ]);
732 let mut scalars = BTreeMap::new();
733 scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
734 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
735 let msg = err.to_string();
736 assert!(msg.contains("Ambiguous"));
737 assert!(msg.contains("customfield_1"));
738 assert!(msg.contains("customfield_2"));
739 }
740
741 #[test]
742 fn array_field_requires_sequence_value() {
743 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
744 let mut scalars = BTreeMap::new();
745 scalars.insert(
746 "Components".to_string(),
747 serde_yaml::Value::String("not-a-sequence".to_string()),
748 );
749 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
750 assert!(format!("{err:#}").contains("expected a sequence"));
751 }
752
753 #[test]
754 fn array_element_must_be_scalar_string() {
755 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
756 let mut scalars = BTreeMap::new();
757 scalars.insert(
758 "Components".to_string(),
759 serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
760 serde_yaml::Value::String("nested".to_string()),
761 ])]),
762 );
763 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
764 assert!(format!("{err:#}").contains("expected a scalar string value"));
765 }
766
767 #[test]
768 fn unsupported_schema_type_errors_with_field_name() {
769 let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
770 let mut scalars = BTreeMap::new();
771 scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
772 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
773 let msg = format!("{err:#}");
774 assert!(msg.contains("Unsupported field type 'user'"));
775 assert!(msg.contains("Reporter"));
776 }
777
778 #[test]
779 fn option_field_accepts_bool_and_number_scalars() {
780 let editmeta = meta(&[
781 (
782 "customfield_bool",
783 "Toggle",
784 "option",
785 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
786 ),
787 (
788 "customfield_num",
789 "Number choice",
790 "option",
791 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
792 ),
793 ]);
794 let mut scalars = BTreeMap::new();
795 scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
796 scalars.insert(
797 "Number choice".to_string(),
798 serde_yaml::Value::Number(3.into()),
799 );
800 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
801 assert_eq!(
802 out.get("customfield_bool").unwrap(),
803 &serde_json::json!({"value": "true"})
804 );
805 assert_eq!(
806 out.get("customfield_num").unwrap(),
807 &serde_json::json!({"value": "3"})
808 );
809 }
810
811 #[test]
812 fn option_field_rejects_non_scalar_value() {
813 let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
814 let mut mapping = serde_yaml::Mapping::new();
815 mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
816 let mut scalars = BTreeMap::new();
817 scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
818 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
819 assert!(format!("{err:#}").contains("expected a scalar string value"));
820 }
821
822 #[test]
823 fn section_with_stale_id_falls_back_to_name_lookup() {
824 let editmeta = meta(&[(
827 "customfield_NEW",
828 "Acceptance Criteria",
829 "string",
830 Some(CUSTOM_TEXTAREA),
831 )]);
832 let sections = [CustomFieldSection {
833 name: "Acceptance Criteria".to_string(),
834 id: "customfield_OLD".to_string(),
835 body: "body".to_string(),
836 }];
837 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
838 assert!(out.contains_key("customfield_NEW"));
839 assert!(!out.contains_key("customfield_OLD"));
840 }
841
842 #[test]
843 fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
844 let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
847 let mut scalars = BTreeMap::new();
848 scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
849 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
850 assert!(err.to_string().contains("Unknown custom field"));
851 }
852
853 #[test]
856 fn parse_set_field_bare_string_value() {
857 let (name, value) = parse_set_field("Status=Open").unwrap();
858 assert_eq!(name, "Status");
859 assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
860 }
861
862 #[test]
863 fn parse_set_field_numeric_value_becomes_number() {
864 let (_name, value) = parse_set_field("Points=8").unwrap();
865 assert_eq!(value, serde_yaml::Value::Number(8.into()));
866 }
867
868 #[test]
869 fn parse_set_field_bool_value_becomes_bool() {
870 let (_name, value) = parse_set_field("Enabled=true").unwrap();
871 assert_eq!(value, serde_yaml::Value::Bool(true));
872 }
873
874 #[test]
875 fn parse_set_field_preserves_spaces_in_name() {
876 let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
877 assert_eq!(name, "Planned / Unplanned Work");
878 assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
879 }
880
881 #[test]
882 fn parse_set_field_equals_in_value_preserved() {
883 let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
885 assert_eq!(name, "Formula");
886 assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
887 }
888
889 #[test]
890 fn parse_set_field_requires_equals() {
891 let err = parse_set_field("just-a-name").unwrap_err();
892 assert!(err.to_string().contains("expected --set-field"));
893 }
894
895 #[test]
896 fn parse_set_field_empty_name_errors() {
897 let err = parse_set_field("=value").unwrap_err();
898 assert!(err.to_string().contains("non-empty name"));
899 }
900
901 #[test]
902 fn merge_set_field_overrides_cli_wins() {
903 let mut frontmatter = BTreeMap::new();
904 frontmatter.insert(
905 "Priority".to_string(),
906 serde_yaml::Value::String("Low".to_string()),
907 );
908 frontmatter.insert(
909 "Keep".to_string(),
910 serde_yaml::Value::String("from-fm".to_string()),
911 );
912 let overrides = vec![(
913 "Priority".to_string(),
914 serde_yaml::Value::String("High".to_string()),
915 )];
916 let merged = merge_set_field_overrides(frontmatter, overrides);
917 assert_eq!(
918 merged.get("Priority"),
919 Some(&serde_yaml::Value::String("High".to_string()))
920 );
921 assert_eq!(
922 merged.get("Keep"),
923 Some(&serde_yaml::Value::String("from-fm".to_string()))
924 );
925 }
926
927 #[test]
928 fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
929 let mut frontmatter = BTreeMap::new();
930 frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
931 let merged = merge_set_field_overrides(frontmatter, vec![]);
932 assert_eq!(merged.len(), 1);
933 assert_eq!(
934 merged.get("K"),
935 Some(&serde_yaml::Value::String("v".to_string()))
936 );
937 }
938
939 #[test]
940 fn section_prefers_tag_id_over_name_lookup() {
941 let editmeta = meta(&[(
944 "customfield_19300",
945 "Acceptance Criteria",
946 "string",
947 Some(CUSTOM_TEXTAREA),
948 )]);
949 let sections = [CustomFieldSection {
950 name: "Acceptance Criteria".to_string(),
951 id: "customfield_19300".to_string(),
952 body: "body".to_string(),
953 }];
954 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
955 assert!(out.contains_key("customfield_19300"));
956 }
957
958 #[test]
961 fn convert_textarea_string_value_converts_to_adf() {
962 let editmeta = meta(&[(
963 "customfield_19300",
964 "Acceptance Criteria",
965 "string",
966 Some(CUSTOM_TEXTAREA),
967 )]);
968 let mut fields = BTreeMap::new();
969 fields.insert(
970 "customfield_19300".to_string(),
971 serde_json::Value::String("- one\n- two".to_string()),
972 );
973 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
974 let value = fields.get("customfield_19300").unwrap();
975 assert_eq!(value["type"], "doc");
976 assert_eq!(value["version"], 1);
977 assert!(value["content"].is_array());
978 }
979
980 #[test]
981 fn convert_textarea_object_value_passes_through() {
982 let editmeta = meta(&[(
983 "customfield_19300",
984 "Acceptance Criteria",
985 "string",
986 Some(CUSTOM_TEXTAREA),
987 )]);
988 let raw_adf = serde_json::json!({
989 "version": 1,
990 "type": "doc",
991 "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
992 });
993 let mut fields = BTreeMap::new();
994 fields.insert("customfield_19300".to_string(), raw_adf.clone());
995 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
996 assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
997 }
998
999 #[test]
1000 fn convert_textarea_empty_string_clears_field() {
1001 let editmeta = meta(&[(
1002 "customfield_19300",
1003 "Acceptance Criteria",
1004 "string",
1005 Some(CUSTOM_TEXTAREA),
1006 )]);
1007 let mut fields = BTreeMap::new();
1008 fields.insert(
1009 "customfield_19300".to_string(),
1010 serde_json::Value::String(String::new()),
1011 );
1012 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1013 assert_eq!(
1014 fields.get("customfield_19300").unwrap(),
1015 &serde_json::Value::Null
1016 );
1017 }
1018
1019 #[test]
1020 fn convert_non_textarea_string_passes_through() {
1021 let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
1022 let mut fields = BTreeMap::new();
1023 fields.insert(
1024 "customfield_10010".to_string(),
1025 serde_json::Value::String("plain".to_string()),
1026 );
1027 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1028 assert_eq!(
1029 fields.get("customfield_10010").unwrap(),
1030 &serde_json::Value::String("plain".to_string())
1031 );
1032 }
1033
1034 #[test]
1035 fn convert_unknown_field_passes_through() {
1036 let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
1039 let mut fields = BTreeMap::new();
1040 fields.insert(
1041 "customfield_99999".to_string(),
1042 serde_json::Value::String("- a".to_string()),
1043 );
1044 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1045 assert_eq!(
1046 fields.get("customfield_99999").unwrap(),
1047 &serde_json::Value::String("- a".to_string())
1048 );
1049 }
1050
1051 #[test]
1052 fn convert_textarea_non_string_non_object_passes_through() {
1053 let editmeta = meta(&[(
1056 "customfield_19300",
1057 "Acceptance Criteria",
1058 "string",
1059 Some(CUSTOM_TEXTAREA),
1060 )]);
1061 let mut fields = BTreeMap::new();
1062 fields.insert(
1063 "customfield_19300".to_string(),
1064 serde_json::Value::Number(42.into()),
1065 );
1066 convert_textarea_string_values(&mut fields, &editmeta).unwrap();
1067 assert_eq!(
1068 fields.get("customfield_19300").unwrap(),
1069 &serde_json::Value::Number(42.into())
1070 );
1071 }
1072
1073 #[test]
1074 fn convert_textarea_invalid_adf_nesting_errors() {
1075 let editmeta = meta(&[(
1076 "customfield_19300",
1077 "Acceptance Criteria",
1078 "string",
1079 Some(CUSTOM_TEXTAREA),
1080 )]);
1081 let mut fields = BTreeMap::new();
1082 fields.insert(
1083 "customfield_19300".to_string(),
1084 serde_json::Value::String(
1085 ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
1086 ),
1087 );
1088 let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
1089 let msg = format!("{err:#}");
1090 assert!(msg.contains("Acceptance Criteria"));
1091 assert!(msg.contains("ADF nesting validation"));
1092 assert!(msg.contains("`expand` cannot be a child of `panel`"));
1093 }
1094}