1use std::collections::BTreeMap;
12
13use anyhow::{anyhow, bail, Context, Result};
14
15use crate::atlassian::client::{EditMeta, EditMetaField};
16use crate::atlassian::convert::markdown_to_adf;
17use crate::atlassian::document::CustomFieldSection;
18
19#[cfg(test)]
23const CUSTOM_TEXTAREA: &str = "com.atlassian.jira.plugin.system.customfieldtypes:textarea";
24
25pub 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 bail!(
48 "Field '{}' ({}) is a rich-text field; set it via a `<!-- field: {} ({}) -->` section in the body, not as a scalar in frontmatter",
49 field.name, id, field.name, id
50 );
51 }
52 let payload = scalar_to_api_value(value, field).with_context(|| {
53 format!(
54 "Failed to convert custom field '{}' ({}) to API value",
55 field.name, id
56 )
57 })?;
58 out.insert(id, payload);
59 }
60
61 for section in sections {
62 let (id, field) = resolve_section_field(editmeta, section)?;
63 if !field.is_adf_rich_text() {
64 bail!(
65 "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
66 field.name, id
67 );
68 }
69 let adf = markdown_to_adf(§ion.body).with_context(|| {
70 format!(
71 "Failed to convert body for custom field '{}' ({}) to ADF",
72 field.name, id
73 )
74 })?;
75 let value =
76 serde_json::to_value(&adf).context("Failed to serialize custom field ADF document")?;
77 out.insert(id, value);
78 }
79
80 Ok(out)
81}
82
83fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
86 if looks_like_field_id(key) {
87 if let Some(field) = editmeta.fields.get(key) {
88 return Ok((key.to_string(), field));
89 }
90 }
93
94 let matches: Vec<_> = editmeta
95 .fields
96 .iter()
97 .filter(|(_, f)| f.name == key)
98 .collect();
99
100 match matches.as_slice() {
101 [] => {
102 let candidates = editmeta
103 .fields
104 .iter()
105 .map(|(id, f)| format!(" {id} {}", f.name))
106 .collect::<Vec<_>>()
107 .join("\n");
108 Err(anyhow!(
109 "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
110 ))
111 }
112 [(id, field)] => Ok(((*id).clone(), field)),
113 multi => {
114 let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
115 Err(anyhow!(
116 "Ambiguous custom field '{key}' matches multiple IDs: {}",
117 ids.join(", ")
118 ))
119 }
120 }
121}
122
123fn resolve_section_field<'a>(
126 editmeta: &'a EditMeta,
127 section: &CustomFieldSection,
128) -> Result<(String, &'a EditMetaField)> {
129 if let Some(field) = editmeta.fields.get(§ion.id) {
130 return Ok((section.id.clone(), field));
131 }
132 lookup_field(editmeta, §ion.name)
133}
134
135fn looks_like_field_id(s: &str) -> bool {
136 s.starts_with("customfield_") && s[12..].chars().all(|c| c.is_ascii_digit())
137}
138
139fn scalar_to_api_value(
142 value: &serde_yaml::Value,
143 field: &EditMetaField,
144) -> Result<serde_json::Value> {
145 let kind = field.schema.kind.as_str();
146 let custom = field.schema.custom.as_deref();
147 match (kind, custom) {
148 ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
149 let s = yaml_as_string(value).with_context(|| {
150 format!("expected a string for option field '{}'", field.name)
151 })?;
152 Ok(serde_json::json!({ "value": s }))
153 }
154 ("array", _) => {
155 let seq = value.as_sequence().ok_or_else(|| {
156 anyhow!("expected a sequence for array field '{}'", field.name)
157 })?;
158 let items: Vec<serde_json::Value> = seq
159 .iter()
160 .map(|v| {
161 let s = yaml_as_string(v).with_context(|| {
162 format!(
163 "expected a string array element for field '{}'",
164 field.name
165 )
166 })?;
167 Ok(serde_json::json!({ "value": s }))
168 })
169 .collect::<Result<_>>()?;
170 Ok(serde_json::Value::Array(items))
171 }
172 ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
173 (other, _) => Err(anyhow!(
174 "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
175 field.name
176 )),
177 }
178}
179
180fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
181 match value {
182 serde_yaml::Value::String(s) => Ok(s.clone()),
183 serde_yaml::Value::Bool(b) => Ok(b.to_string()),
184 serde_yaml::Value::Number(n) => Ok(n.to_string()),
185 _ => Err(anyhow!("expected a scalar string value")),
186 }
187}
188
189fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
190 let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
191 serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
192 .context("Failed to convert YAML value to JSON")
193}
194
195pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
201 let (name, value) = input
202 .split_once('=')
203 .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
204 let name = name.trim().to_string();
205 if name.is_empty() {
206 bail!("--set-field requires a non-empty name before '='");
207 }
208 let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
209 .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
210 Ok((name, yaml_value))
211}
212
213pub fn merge_set_field_overrides(
216 frontmatter: BTreeMap<String, serde_yaml::Value>,
217 overrides: Vec<(String, serde_yaml::Value)>,
218) -> BTreeMap<String, serde_yaml::Value> {
219 let mut merged = frontmatter;
220 for (name, value) in overrides {
221 merged.insert(name, value);
222 }
223 merged
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used, clippy::expect_used)]
228mod tests {
229 use super::*;
230 use crate::atlassian::client::{EditMetaField, EditMetaSchema};
231
232 fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
233 let mut fields = BTreeMap::new();
234 for (id, name, kind, custom) in entries {
235 fields.insert(
236 (*id).to_string(),
237 EditMetaField {
238 name: (*name).to_string(),
239 schema: EditMetaSchema {
240 kind: (*kind).to_string(),
241 custom: custom.map(str::to_string),
242 },
243 },
244 );
245 }
246 EditMeta { fields }
247 }
248
249 #[test]
250 fn scalar_option_field_wraps_in_value_object() {
251 let editmeta = meta(&[(
252 "customfield_10001",
253 "Planned / Unplanned Work",
254 "option",
255 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
256 )]);
257 let mut scalars = BTreeMap::new();
258 scalars.insert(
259 "Planned / Unplanned Work".to_string(),
260 serde_yaml::Value::String("Unplanned".to_string()),
261 );
262 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
263 assert_eq!(
264 out.get("customfield_10001").unwrap(),
265 &serde_json::json!({ "value": "Unplanned" })
266 );
267 }
268
269 #[test]
270 fn scalar_radiobutton_wraps_in_value_object() {
271 let editmeta = meta(&[(
272 "customfield_10002",
273 "Risk",
274 "string",
275 Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
276 )]);
277 let mut scalars = BTreeMap::new();
278 scalars.insert(
279 "Risk".to_string(),
280 serde_yaml::Value::String("High".to_string()),
281 );
282 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
283 assert_eq!(
284 out.get("customfield_10002").unwrap(),
285 &serde_json::json!({ "value": "High" })
286 );
287 }
288
289 #[test]
290 fn scalar_number_field_passes_through() {
291 let editmeta = meta(&[(
292 "customfield_10003",
293 "Story points",
294 "number",
295 Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
296 )]);
297 let mut scalars = BTreeMap::new();
298 scalars.insert(
299 "Story points".to_string(),
300 serde_yaml::Value::Number(8.into()),
301 );
302 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
303 assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
304 }
305
306 #[test]
307 fn scalar_array_option_field_wraps_each_item() {
308 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
309 let mut scalars = BTreeMap::new();
310 scalars.insert(
311 "Components".to_string(),
312 serde_yaml::Value::Sequence(vec![
313 serde_yaml::Value::String("backend".to_string()),
314 serde_yaml::Value::String("auth".to_string()),
315 ]),
316 );
317 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
318 assert_eq!(
319 out.get("customfield_10004").unwrap(),
320 &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
321 );
322 }
323
324 #[test]
325 fn scalar_to_rich_text_field_errors() {
326 let editmeta = meta(&[(
327 "customfield_19300",
328 "Acceptance Criteria",
329 "string",
330 Some(CUSTOM_TEXTAREA),
331 )]);
332 let mut scalars = BTreeMap::new();
333 scalars.insert(
334 "Acceptance Criteria".to_string(),
335 serde_yaml::Value::String("just text".to_string()),
336 );
337 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
338 assert!(err.to_string().contains("rich-text field"));
339 }
340
341 #[test]
342 fn rich_text_section_becomes_adf_payload() {
343 let editmeta = meta(&[(
344 "customfield_19300",
345 "Acceptance Criteria",
346 "string",
347 Some(CUSTOM_TEXTAREA),
348 )]);
349 let sections = [CustomFieldSection {
350 name: "Acceptance Criteria".to_string(),
351 id: "customfield_19300".to_string(),
352 body: "- Item one\n- Item two".to_string(),
353 }];
354 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
355 let value = out.get("customfield_19300").unwrap();
356 assert_eq!(value["type"], "doc");
357 assert_eq!(value["version"], 1);
358 assert!(value["content"].is_array());
359 }
360
361 #[test]
362 fn section_pointing_at_non_rich_text_field_errors() {
363 let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
364 let sections = [CustomFieldSection {
365 name: "Priority Flag".to_string(),
366 id: "customfield_10001".to_string(),
367 body: "Some text".to_string(),
368 }];
369 let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
370 assert!(err.to_string().contains("not a rich-text field"));
371 }
372
373 #[test]
374 fn unknown_field_name_errors_with_suggestions() {
375 let editmeta = meta(&[
376 ("customfield_1", "Alpha", "string", None),
377 ("customfield_2", "Beta", "string", None),
378 ]);
379 let mut scalars = BTreeMap::new();
380 scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
381 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
382 let msg = err.to_string();
383 assert!(msg.contains("Unknown custom field 'Gamma'"));
384 assert!(msg.contains("Alpha"));
385 assert!(msg.contains("Beta"));
386 }
387
388 #[test]
389 fn field_id_bypasses_name_lookup() {
390 let editmeta = meta(&[(
391 "customfield_10001",
392 "Planned / Unplanned Work",
393 "option",
394 None,
395 )]);
396 let mut scalars = BTreeMap::new();
397 scalars.insert(
398 "customfield_10001".to_string(),
399 serde_yaml::Value::String("Unplanned".to_string()),
400 );
401 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
402 assert_eq!(
403 out.get("customfield_10001").unwrap(),
404 &serde_json::json!({ "value": "Unplanned" })
405 );
406 }
407
408 #[test]
409 fn ambiguous_field_name_errors_listing_ids() {
410 let editmeta = meta(&[
411 ("customfield_1", "Duplicate", "string", None),
412 ("customfield_2", "Duplicate", "string", None),
413 ]);
414 let mut scalars = BTreeMap::new();
415 scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
416 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
417 let msg = err.to_string();
418 assert!(msg.contains("Ambiguous"));
419 assert!(msg.contains("customfield_1"));
420 assert!(msg.contains("customfield_2"));
421 }
422
423 #[test]
424 fn array_field_requires_sequence_value() {
425 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
426 let mut scalars = BTreeMap::new();
427 scalars.insert(
428 "Components".to_string(),
429 serde_yaml::Value::String("not-a-sequence".to_string()),
430 );
431 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
432 assert!(format!("{err:#}").contains("expected a sequence"));
433 }
434
435 #[test]
436 fn array_element_must_be_scalar_string() {
437 let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
438 let mut scalars = BTreeMap::new();
439 scalars.insert(
440 "Components".to_string(),
441 serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
442 serde_yaml::Value::String("nested".to_string()),
443 ])]),
444 );
445 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
446 assert!(format!("{err:#}").contains("expected a scalar string value"));
447 }
448
449 #[test]
450 fn unsupported_schema_type_errors_with_field_name() {
451 let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
452 let mut scalars = BTreeMap::new();
453 scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
454 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
455 let msg = format!("{err:#}");
456 assert!(msg.contains("Unsupported field type 'user'"));
457 assert!(msg.contains("Reporter"));
458 }
459
460 #[test]
461 fn option_field_accepts_bool_and_number_scalars() {
462 let editmeta = meta(&[
463 (
464 "customfield_bool",
465 "Toggle",
466 "option",
467 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
468 ),
469 (
470 "customfield_num",
471 "Number choice",
472 "option",
473 Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
474 ),
475 ]);
476 let mut scalars = BTreeMap::new();
477 scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
478 scalars.insert(
479 "Number choice".to_string(),
480 serde_yaml::Value::Number(3.into()),
481 );
482 let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
483 assert_eq!(
484 out.get("customfield_bool").unwrap(),
485 &serde_json::json!({"value": "true"})
486 );
487 assert_eq!(
488 out.get("customfield_num").unwrap(),
489 &serde_json::json!({"value": "3"})
490 );
491 }
492
493 #[test]
494 fn option_field_rejects_non_scalar_value() {
495 let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
496 let mut mapping = serde_yaml::Mapping::new();
497 mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
498 let mut scalars = BTreeMap::new();
499 scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
500 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
501 assert!(format!("{err:#}").contains("expected a scalar string value"));
502 }
503
504 #[test]
505 fn section_with_stale_id_falls_back_to_name_lookup() {
506 let editmeta = meta(&[(
509 "customfield_NEW",
510 "Acceptance Criteria",
511 "string",
512 Some(CUSTOM_TEXTAREA),
513 )]);
514 let sections = [CustomFieldSection {
515 name: "Acceptance Criteria".to_string(),
516 id: "customfield_OLD".to_string(),
517 body: "body".to_string(),
518 }];
519 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
520 assert!(out.contains_key("customfield_NEW"));
521 assert!(!out.contains_key("customfield_OLD"));
522 }
523
524 #[test]
525 fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
526 let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
529 let mut scalars = BTreeMap::new();
530 scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
531 let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
532 assert!(err.to_string().contains("Unknown custom field"));
533 }
534
535 #[test]
538 fn parse_set_field_bare_string_value() {
539 let (name, value) = parse_set_field("Status=Open").unwrap();
540 assert_eq!(name, "Status");
541 assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
542 }
543
544 #[test]
545 fn parse_set_field_numeric_value_becomes_number() {
546 let (_name, value) = parse_set_field("Points=8").unwrap();
547 assert_eq!(value, serde_yaml::Value::Number(8.into()));
548 }
549
550 #[test]
551 fn parse_set_field_bool_value_becomes_bool() {
552 let (_name, value) = parse_set_field("Enabled=true").unwrap();
553 assert_eq!(value, serde_yaml::Value::Bool(true));
554 }
555
556 #[test]
557 fn parse_set_field_preserves_spaces_in_name() {
558 let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
559 assert_eq!(name, "Planned / Unplanned Work");
560 assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
561 }
562
563 #[test]
564 fn parse_set_field_equals_in_value_preserved() {
565 let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
567 assert_eq!(name, "Formula");
568 assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
569 }
570
571 #[test]
572 fn parse_set_field_requires_equals() {
573 let err = parse_set_field("just-a-name").unwrap_err();
574 assert!(err.to_string().contains("expected --set-field"));
575 }
576
577 #[test]
578 fn parse_set_field_empty_name_errors() {
579 let err = parse_set_field("=value").unwrap_err();
580 assert!(err.to_string().contains("non-empty name"));
581 }
582
583 #[test]
584 fn merge_set_field_overrides_cli_wins() {
585 let mut frontmatter = BTreeMap::new();
586 frontmatter.insert(
587 "Priority".to_string(),
588 serde_yaml::Value::String("Low".to_string()),
589 );
590 frontmatter.insert(
591 "Keep".to_string(),
592 serde_yaml::Value::String("from-fm".to_string()),
593 );
594 let overrides = vec![(
595 "Priority".to_string(),
596 serde_yaml::Value::String("High".to_string()),
597 )];
598 let merged = merge_set_field_overrides(frontmatter, overrides);
599 assert_eq!(
600 merged.get("Priority"),
601 Some(&serde_yaml::Value::String("High".to_string()))
602 );
603 assert_eq!(
604 merged.get("Keep"),
605 Some(&serde_yaml::Value::String("from-fm".to_string()))
606 );
607 }
608
609 #[test]
610 fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
611 let mut frontmatter = BTreeMap::new();
612 frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
613 let merged = merge_set_field_overrides(frontmatter, vec![]);
614 assert_eq!(merged.len(), 1);
615 assert_eq!(
616 merged.get("K"),
617 Some(&serde_yaml::Value::String("v".to_string()))
618 );
619 }
620
621 #[test]
622 fn section_prefers_tag_id_over_name_lookup() {
623 let editmeta = meta(&[(
626 "customfield_19300",
627 "Acceptance Criteria",
628 "string",
629 Some(CUSTOM_TEXTAREA),
630 )]);
631 let sections = [CustomFieldSection {
632 name: "Acceptance Criteria".to_string(),
633 id: "customfield_19300".to_string(),
634 body: "body".to_string(),
635 }];
636 let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
637 assert!(out.contains_key("customfield_19300"));
638 }
639}