use std::collections::BTreeMap;
use anyhow::{anyhow, bail, Context, Result};
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::adf_validated::{markdown_to_validated_adf, ValidatedAdfDocument};
use crate::atlassian::document::CustomFieldSection;
use crate::atlassian::jira_types::{EditMeta, EditMetaField};
#[cfg(test)]
use crate::atlassian::jira_types::TEXTAREA_CUSTOM_TYPE as CUSTOM_TEXTAREA;
pub fn resolve_custom_fields(
scalars: &BTreeMap<String, serde_yaml::Value>,
sections: &[CustomFieldSection],
editmeta: &EditMeta,
) -> Result<BTreeMap<String, serde_json::Value>> {
let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for (key, value) in scalars {
let (id, field) = lookup_field(editmeta, key)?;
if field.is_adf_rich_text() {
let payload = rich_text_scalar_to_api_value(value, field, &id)?;
out.insert(id, payload);
continue;
}
let payload = scalar_to_api_value(value, field, &id).with_context(|| {
format!(
"Failed to convert custom field '{}' ({}) to API value",
field.name, id
)
})?;
out.insert(id, payload);
}
for section in sections {
let (id, field) = resolve_section_field(editmeta, section)?;
if !field.is_adf_rich_text() {
bail!(
"Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
field.name, id
);
}
let validated = markdown_to_validated_adf(§ion.body).with_context(|| {
format!(
"Custom field '{}' ({}) failed ADF nesting validation",
field.name, id
)
})?;
let value = serde_json::to_value(&validated)
.context("Failed to serialize custom field ADF document")?;
out.insert(id, value);
}
Ok(out)
}
fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
if let Some(field) = editmeta.fields.get(key) {
return Ok((key.to_string(), field));
}
let matches: Vec<_> = editmeta
.fields
.iter()
.filter(|(_, f)| f.name == key)
.collect();
match matches.as_slice() {
[] => {
let candidates = editmeta
.fields
.iter()
.map(|(id, f)| format!(" {id} {}", f.name))
.collect::<Vec<_>>()
.join("\n");
Err(anyhow!(
"Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
))
}
[(id, field)] => Ok(((*id).clone(), field)),
multi => {
let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
Err(anyhow!(
"Ambiguous custom field '{key}' matches multiple IDs: {}",
ids.join(", ")
))
}
}
}
fn resolve_section_field<'a>(
editmeta: &'a EditMeta,
section: &CustomFieldSection,
) -> Result<(String, &'a EditMetaField)> {
if let Some(field) = editmeta.fields.get(§ion.id) {
return Ok((section.id.clone(), field));
}
lookup_field(editmeta, §ion.name)
}
fn rich_text_scalar_to_api_value(
value: &serde_yaml::Value,
field: &EditMetaField,
id: &str,
) -> Result<serde_json::Value> {
let s = match value {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Null => String::new(),
serde_yaml::Value::Mapping(_) => return adf_mapping_to_api_value(value, field, id),
_ => bail!(
"Field '{}' ({}) is a rich-text field; supply JFM markdown as a string or use a `<!-- field: {} ({}) -->` body section",
field.name,
id,
field.name,
id
),
};
string_to_rich_text_api_value(&s, &field.name, id)
}
fn adf_mapping_to_api_value(
value: &serde_yaml::Value,
field: &EditMetaField,
id: &str,
) -> Result<serde_json::Value> {
let json = yaml_to_json(value)?;
if json.get("type").and_then(serde_json::Value::as_str) != Some("doc") {
bail!(
"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)",
field.name,
id
);
}
let doc: AdfDocument = serde_json::from_value(json).with_context(|| {
format!(
"Custom field '{}' ({}) value is not a valid ADF document",
field.name, id
)
})?;
let validated = ValidatedAdfDocument::try_new(doc).with_context(|| {
format!(
"Custom field '{}' ({}) failed ADF nesting validation",
field.name, id
)
})?;
serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
}
fn string_to_rich_text_api_value(s: &str, field_name: &str, id: &str) -> Result<serde_json::Value> {
if s.is_empty() {
return Ok(serde_json::Value::Null);
}
let validated = markdown_to_validated_adf(s).with_context(|| {
format!("Custom field '{field_name}' ({id}) failed ADF nesting validation")
})?;
serde_json::to_value(&validated).context("Failed to serialize custom field ADF document")
}
pub fn convert_textarea_string_values(
fields: &mut BTreeMap<String, serde_json::Value>,
editmeta: &EditMeta,
) -> Result<()> {
for (id, value) in fields.iter_mut() {
let Some(field) = editmeta.fields.get(id) else {
continue;
};
if !field.is_adf_rich_text() {
continue;
}
let serde_json::Value::String(s) = value else {
continue;
};
*value = string_to_rich_text_api_value(s, &field.name, id)?;
}
Ok(())
}
fn scalar_to_api_value(
value: &serde_yaml::Value,
field: &EditMetaField,
id: &str,
) -> Result<serde_json::Value> {
let kind = field.schema.kind.as_str();
let custom = field.schema.custom.as_deref();
match (kind, custom) {
("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
let s = yaml_as_string(value).with_context(|| {
format!("expected a string for option field '{}'", field.name)
})?;
validate_option_value(field, id, &s)?;
Ok(serde_json::json!({ "value": s }))
}
("array", _) => {
let wrap: fn(String) -> serde_json::Value = match field.schema.items.as_deref() {
Some("string") => serde_json::Value::String,
Some("component" | "version") => |s| serde_json::json!({ "name": s }),
_ => |s| serde_json::json!({ "value": s }),
};
let elements: Vec<String> = match value {
serde_yaml::Value::Sequence(seq) => seq
.iter()
.map(|v| {
yaml_as_string(v).with_context(|| {
format!(
"expected a string array element for field '{}'",
field.name
)
})
})
.collect::<Result<_>>()?,
serde_yaml::Value::String(_)
| serde_yaml::Value::Number(_)
| serde_yaml::Value::Bool(_) => yaml_as_string(value)?
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(String::from)
.collect(),
_ => bail!(
"expected a sequence (e.g. [a, b]) or comma-separated string (e.g. a,b) for array field '{}'",
field.name
),
};
let items: Vec<serde_json::Value> = elements
.into_iter()
.map(|s| {
validate_option_value(field, id, &s)?;
Ok(wrap(s))
})
.collect::<Result<_>>()?;
Ok(serde_json::Value::Array(items))
}
("issuelink", _) => match value {
serde_yaml::Value::String(s) => Ok(serde_json::json!({ "key": s })),
serde_yaml::Value::Mapping(_) => yaml_to_json(value),
_ => Err(anyhow!(
"expected an issue key string (e.g. 'PROJ-123') for issue-link field '{}'",
field.name
)),
},
("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
(other, _) => Err(anyhow!(
"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",
field.name
)),
}
}
fn validate_option_value(field: &EditMetaField, id: &str, value: &str) -> Result<()> {
if field.allowed_values.is_empty() || field.allowed_values.iter().any(|v| v == value) {
return Ok(());
}
bail!(
"Field '{}' ({}): '{}' is not an allowed option. Valid options: {}",
field.name,
id,
value,
field.allowed_values.join(", ")
)
}
fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
match value {
serde_yaml::Value::String(s) => Ok(s.clone()),
serde_yaml::Value::Bool(b) => Ok(b.to_string()),
serde_yaml::Value::Number(n) => Ok(n.to_string()),
_ => Err(anyhow!("expected a scalar string value")),
}
}
fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
.context("Failed to convert YAML value to JSON")
}
pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
let (name, value) = input
.split_once('=')
.ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
let name = name.trim().to_string();
if name.is_empty() {
bail!("--set-field requires a non-empty name before '='");
}
let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
.unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
Ok((name, yaml_value))
}
pub fn user_field_value(raw: &str) -> serde_json::Value {
if raw.is_empty() {
serde_json::Value::Null
} else {
serde_json::json!({ "accountId": raw })
}
}
pub fn apply_user_field_overrides(
fields: &mut BTreeMap<String, serde_json::Value>,
assignee: Option<&str>,
reporter: Option<&str>,
other_source_label: &str,
) -> Result<()> {
if let Some(value) = assignee {
if fields.contains_key("assignee") {
bail!("`assignee` collides with {other_source_label}; supply only one");
}
fields.insert("assignee".to_string(), user_field_value(value));
}
if let Some(value) = reporter {
if fields.contains_key("reporter") {
bail!("`reporter` collides with {other_source_label}; supply only one");
}
fields.insert("reporter".to_string(), user_field_value(value));
}
Ok(())
}
pub fn merge_set_field_overrides(
frontmatter: BTreeMap<String, serde_yaml::Value>,
overrides: Vec<(String, serde_yaml::Value)>,
) -> BTreeMap<String, serde_yaml::Value> {
let mut merged = frontmatter;
for (name, value) in overrides {
merged.insert(name, value);
}
merged
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::jira_types::{EditMetaField, EditMetaSchema};
fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
let mut fields = BTreeMap::new();
for (id, name, kind, custom) in entries {
fields.insert(
(*id).to_string(),
EditMetaField {
name: (*name).to_string(),
schema: EditMetaSchema {
kind: (*kind).to_string(),
custom: custom.map(str::to_string),
..EditMetaSchema::default()
},
allowed_values: Vec::new(),
},
);
}
EditMeta { fields }
}
fn meta_with_schema(id: &str, name: &str, schema: EditMetaSchema) -> EditMeta {
let mut fields = BTreeMap::new();
fields.insert(
id.to_string(),
EditMetaField {
name: name.to_string(),
schema,
allowed_values: Vec::new(),
},
);
EditMeta { fields }
}
fn meta_with_allowed(id: &str, name: &str, kind: &str, allowed: &[&str]) -> EditMeta {
let mut fields = BTreeMap::new();
fields.insert(
id.to_string(),
EditMetaField {
name: name.to_string(),
schema: EditMetaSchema {
kind: kind.to_string(),
..EditMetaSchema::default()
},
allowed_values: allowed.iter().map(|v| (*v).to_string()).collect(),
},
);
EditMeta { fields }
}
#[test]
fn user_field_value_empty_string_is_null() {
assert_eq!(user_field_value(""), serde_json::Value::Null);
}
#[test]
fn user_field_value_account_id_wrapped() {
assert_eq!(
user_field_value("abc123"),
serde_json::json!({"accountId": "abc123"})
);
}
#[test]
fn user_field_value_dash_one_preserves_auto_assign() {
assert_eq!(
user_field_value("-1"),
serde_json::json!({"accountId": "-1"})
);
}
#[test]
fn apply_user_field_overrides_inserts_assignee_and_reporter() {
let mut fields = BTreeMap::new();
apply_user_field_overrides(&mut fields, Some("a1"), Some("r1"), "ignored").unwrap();
assert_eq!(
fields.get("assignee"),
Some(&serde_json::json!({"accountId": "a1"}))
);
assert_eq!(
fields.get("reporter"),
Some(&serde_json::json!({"accountId": "r1"}))
);
}
#[test]
fn apply_user_field_overrides_skips_none() {
let mut fields = BTreeMap::new();
apply_user_field_overrides(&mut fields, None, None, "ignored").unwrap();
assert!(fields.is_empty());
}
#[test]
fn apply_user_field_overrides_empty_string_clears() {
let mut fields = BTreeMap::new();
apply_user_field_overrides(&mut fields, Some(""), None, "ignored").unwrap();
assert_eq!(fields.get("assignee"), Some(&serde_json::Value::Null));
}
#[test]
fn apply_user_field_overrides_assignee_collision_errors() {
let mut fields = BTreeMap::new();
fields.insert(
"assignee".to_string(),
serde_json::json!({"accountId": "existing"}),
);
let err = apply_user_field_overrides(&mut fields, Some("new"), None, "the test source")
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("assignee"));
assert!(msg.contains("the test source"));
}
#[test]
fn apply_user_field_overrides_reporter_collision_errors() {
let mut fields = BTreeMap::new();
fields.insert(
"reporter".to_string(),
serde_json::json!({"accountId": "existing"}),
);
let err = apply_user_field_overrides(&mut fields, None, Some("new"), "the test source")
.unwrap_err();
assert!(err.to_string().contains("reporter"));
}
#[test]
fn scalar_option_field_wraps_in_value_object() {
let editmeta = meta(&[(
"customfield_10001",
"Planned / Unplanned Work",
"option",
Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Planned / Unplanned Work".to_string(),
serde_yaml::Value::String("Unplanned".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10001").unwrap(),
&serde_json::json!({ "value": "Unplanned" })
);
}
#[test]
fn scalar_radiobutton_wraps_in_value_object() {
let editmeta = meta(&[(
"customfield_10002",
"Risk",
"string",
Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Risk".to_string(),
serde_yaml::Value::String("High".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10002").unwrap(),
&serde_json::json!({ "value": "High" })
);
}
#[test]
fn scalar_number_field_passes_through() {
let editmeta = meta(&[(
"customfield_10003",
"Story points",
"number",
Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Story points".to_string(),
serde_yaml::Value::Number(8.into()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
}
#[test]
fn scalar_array_option_field_wraps_each_item() {
let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("backend".to_string()),
serde_yaml::Value::String("auth".to_string()),
]),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10004").unwrap(),
&serde_json::json!([{"value": "backend"}, {"value": "auth"}])
);
}
#[test]
fn scalar_string_array_field_emits_plain_strings() {
let editmeta = meta_with_schema(
"labels",
"Labels",
EditMetaSchema {
kind: "array".to_string(),
items: Some("string".to_string()),
system: Some("labels".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Labels".to_string(),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("lock-state-v2".to_string()),
serde_yaml::Value::String("phase-1".to_string()),
]),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("labels").unwrap(),
&serde_json::json!(["lock-state-v2", "phase-1"])
);
}
#[test]
fn scalar_custom_labels_field_emits_plain_strings() {
let editmeta = meta_with_schema(
"customfield_10050",
"Team Tags",
EditMetaSchema {
kind: "array".to_string(),
custom: Some(
"com.atlassian.jira.plugin.system.customfieldtypes:labels".to_string(),
),
items: Some("string".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Team Tags".to_string(),
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("infra".to_string())]),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10050").unwrap(),
&serde_json::json!(["infra"])
);
}
#[test]
fn scalar_component_array_field_wraps_in_name_objects() {
let editmeta = meta_with_schema(
"components",
"Components",
EditMetaSchema {
kind: "array".to_string(),
items: Some("component".to_string()),
system: Some("components".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("backend".to_string()),
serde_yaml::Value::String("auth".to_string()),
]),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("components").unwrap(),
&serde_json::json!([{"name": "backend"}, {"name": "auth"}])
);
}
fn labels_meta() -> EditMeta {
meta_with_schema(
"labels",
"Labels",
EditMetaSchema {
kind: "array".to_string(),
items: Some("string".to_string()),
system: Some("labels".to_string()),
..EditMetaSchema::default()
},
)
}
#[test]
fn comma_separated_string_array_field_splits_elements() {
let mut scalars = BTreeMap::new();
scalars.insert(
"Labels".to_string(),
serde_yaml::Value::String("a,b,c".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
assert_eq!(
out.get("labels").unwrap(),
&serde_json::json!(["a", "b", "c"])
);
}
#[test]
fn comma_separated_option_array_field_wraps_each_value() {
let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::String("backend,auth".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10004").unwrap(),
&serde_json::json!([{"value": "backend"}, {"value": "auth"}])
);
}
#[test]
fn comma_separated_component_array_field_wraps_in_name_objects() {
let editmeta = meta_with_schema(
"components",
"Components",
EditMetaSchema {
kind: "array".to_string(),
items: Some("component".to_string()),
system: Some("components".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::String("backend,auth".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("components").unwrap(),
&serde_json::json!([{"name": "backend"}, {"name": "auth"}])
);
}
#[test]
fn comma_split_trims_whitespace_and_drops_empty_elements() {
let mut scalars = BTreeMap::new();
scalars.insert(
"Labels".to_string(),
serde_yaml::Value::String(" a , , b ,".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a", "b"]));
}
#[test]
fn comma_split_elements_validate_against_allowed_values() {
let editmeta = meta_with_allowed("customfield_10004", "Components", "array", &["x", "y"]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::String("x,z".to_string()),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("not an allowed option"));
}
#[test]
fn array_field_number_scalar_becomes_single_element() {
let mut scalars = BTreeMap::new();
scalars.insert("Labels".to_string(), serde_yaml::Value::from(5));
let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["5"]));
}
#[test]
fn array_field_empty_string_sends_empty_array() {
let mut scalars = BTreeMap::new();
scalars.insert(
"Labels".to_string(),
serde_yaml::Value::String(String::new()),
);
let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
assert_eq!(out.get("labels").unwrap(), &serde_json::json!([]));
}
#[test]
fn sequence_element_containing_comma_is_not_split() {
let mut scalars = BTreeMap::new();
scalars.insert(
"Labels".to_string(),
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("a,b".to_string())]),
);
let out = resolve_custom_fields(&scalars, &[], &labels_meta()).unwrap();
assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a,b"]));
}
#[test]
fn scalar_issuelink_field_wraps_key() {
let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Parent".to_string(),
serde_yaml::Value::String("PROJ-123".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("parent").unwrap(),
&serde_json::json!({"key": "PROJ-123"})
);
}
#[test]
fn scalar_issuelink_field_passes_through_mapping() {
let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Parent".to_string(),
serde_yaml::from_str("id: '10042'").unwrap(),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("parent").unwrap(),
&serde_json::json!({"id": "10042"})
);
}
#[test]
fn scalar_issuelink_field_rejects_non_string() {
let editmeta = meta(&[("parent", "Parent", "issuelink", None)]);
let mut scalars = BTreeMap::new();
scalars.insert("Parent".to_string(), serde_yaml::Value::Number(7.into()));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("expected an issue key string"));
}
#[test]
fn system_field_id_resolves_without_customfield_prefix() {
let editmeta = meta_with_schema(
"labels",
"Labels",
EditMetaSchema {
kind: "array".to_string(),
items: Some("string".to_string()),
system: Some("labels".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"labels".to_string(),
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("a".to_string())]),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(out.get("labels").unwrap(), &serde_json::json!(["a"]));
}
#[test]
fn scalar_string_to_system_description_converts_jfm_to_adf() {
let editmeta = meta_with_schema(
"description",
"Description",
EditMetaSchema {
kind: "string".to_string(),
system: Some("description".to_string()),
..EditMetaSchema::default()
},
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Description".to_string(),
serde_yaml::Value::String("A paragraph.".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
let value = out.get("description").unwrap();
assert_eq!(value["type"], "doc");
assert_eq!(value["version"], 1);
}
#[test]
fn rich_text_field_accepts_explicit_adf_mapping() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let adf_yaml: serde_yaml::Value = serde_yaml::from_str(
r"
type: doc
version: 1
content:
- type: paragraph
content:
- type: text
text: hand-built
",
)
.unwrap();
let mut scalars = BTreeMap::new();
scalars.insert("Acceptance Criteria".to_string(), adf_yaml);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
let value = out.get("customfield_19300").unwrap();
assert_eq!(value["type"], "doc");
assert_eq!(value["content"][0]["content"][0]["text"], "hand-built");
}
#[test]
fn rich_text_field_rejects_mapping_without_doc_type() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::from_str("some: mapping").unwrap(),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("must be a raw ADF document with `type: doc`"));
}
#[test]
fn rich_text_field_rejects_malformed_adf_document() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::from_str("{type: doc, version: 1, content: nope}").unwrap(),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(
format!("{err:#}").contains("is not a valid ADF document"),
"got: {err:#}"
);
}
#[test]
fn rich_text_field_rejects_adf_mapping_with_invalid_nesting() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let adf_yaml: serde_yaml::Value = serde_yaml::from_str(
r"
type: doc
version: 1
content:
- type: panel
attrs:
panelType: info
content:
- type: expand
attrs:
title: x
content:
- type: paragraph
content:
- type: text
text: body
",
)
.unwrap();
let mut scalars = BTreeMap::new();
scalars.insert("Acceptance Criteria".to_string(), adf_yaml);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("ADF nesting validation"), "got: {msg}");
}
#[test]
fn scalar_string_to_rich_text_field_converts_jfm_to_adf() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::Value::String("- one\n- two".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
let value = out.get("customfield_19300").unwrap();
assert_eq!(value["type"], "doc");
assert_eq!(value["version"], 1);
assert!(value["content"].is_array());
}
#[test]
fn scalar_empty_string_to_rich_text_field_clears() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::Value::String(String::new()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_19300").unwrap(),
&serde_json::Value::Null
);
}
#[test]
fn scalar_yaml_null_to_rich_text_field_clears() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert("Acceptance Criteria".to_string(), serde_yaml::Value::Null);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_19300").unwrap(),
&serde_json::Value::Null
);
}
#[test]
fn scalar_non_string_to_rich_text_field_errors() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::Value::Number(42.into()),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("rich-text field"), "got: {msg}");
assert!(msg.contains("JFM markdown"), "got: {msg}");
}
#[test]
fn scalar_string_with_invalid_adf_nesting_to_rich_text_field_errors() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Acceptance Criteria".to_string(),
serde_yaml::Value::String(
":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Acceptance Criteria"));
assert!(msg.contains("ADF nesting validation"));
assert!(msg.contains("`expand` cannot be a child of `panel`"));
}
#[test]
fn rich_text_section_becomes_adf_payload() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let sections = [CustomFieldSection {
name: "Acceptance Criteria".to_string(),
id: "customfield_19300".to_string(),
body: "- Item one\n- Item two".to_string(),
}];
let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
let value = out.get("customfield_19300").unwrap();
assert_eq!(value["type"], "doc");
assert_eq!(value["version"], 1);
assert!(value["content"].is_array());
}
#[test]
fn rich_text_section_with_invalid_adf_nesting_errors() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let sections = [CustomFieldSection {
name: "Acceptance Criteria".to_string(),
id: "customfield_19300".to_string(),
body: ":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
}];
let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Acceptance Criteria"));
assert!(msg.contains("ADF nesting validation"));
assert!(msg.contains("`expand` cannot be a child of `panel`"));
}
#[test]
fn section_pointing_at_non_rich_text_field_errors() {
let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
let sections = [CustomFieldSection {
name: "Priority Flag".to_string(),
id: "customfield_10001".to_string(),
body: "Some text".to_string(),
}];
let err = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap_err();
assert!(err.to_string().contains("not a rich-text field"));
}
#[test]
fn unknown_field_name_errors_with_suggestions() {
let editmeta = meta(&[
("customfield_1", "Alpha", "string", None),
("customfield_2", "Beta", "string", None),
]);
let mut scalars = BTreeMap::new();
scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown custom field 'Gamma'"));
assert!(msg.contains("Alpha"));
assert!(msg.contains("Beta"));
}
#[test]
fn field_id_bypasses_name_lookup() {
let editmeta = meta(&[(
"customfield_10001",
"Planned / Unplanned Work",
"option",
None,
)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"customfield_10001".to_string(),
serde_yaml::Value::String("Unplanned".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10001").unwrap(),
&serde_json::json!({ "value": "Unplanned" })
);
}
#[test]
fn ambiguous_field_name_errors_listing_ids() {
let editmeta = meta(&[
("customfield_1", "Duplicate", "string", None),
("customfield_2", "Duplicate", "string", None),
]);
let mut scalars = BTreeMap::new();
scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Ambiguous"));
assert!(msg.contains("customfield_1"));
assert!(msg.contains("customfield_2"));
}
#[test]
fn array_field_scalar_without_comma_becomes_single_element() {
let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::String("solo".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_10004").unwrap(),
&serde_json::json!([{"value": "solo"}])
);
}
#[test]
fn array_field_rejects_mapping_value() {
let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("expected a sequence"));
assert!(msg.contains("comma-separated"));
}
#[test]
fn array_element_must_be_scalar_string() {
let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Components".to_string(),
serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("nested".to_string()),
])]),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("expected a scalar string value"));
}
#[test]
fn unsupported_schema_type_errors_with_field_name() {
let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
let mut scalars = BTreeMap::new();
scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Unsupported field type 'user'"));
assert!(msg.contains("Reporter"));
}
#[test]
fn option_field_accepts_bool_and_number_scalars() {
let editmeta = meta(&[
(
"customfield_bool",
"Toggle",
"option",
Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
),
(
"customfield_num",
"Number choice",
"option",
Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
),
]);
let mut scalars = BTreeMap::new();
scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
scalars.insert(
"Number choice".to_string(),
serde_yaml::Value::Number(3.into()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_bool").unwrap(),
&serde_json::json!({"value": "true"})
);
assert_eq!(
out.get("customfield_num").unwrap(),
&serde_json::json!({"value": "3"})
);
}
#[test]
fn option_value_matching_allowed_values_passes() {
let editmeta = meta_with_allowed(
"customfield_21051",
"Work Attribution",
"option",
&["Planned", "Unplanned"],
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Work Attribution".to_string(),
serde_yaml::Value::String("Planned".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_21051").unwrap(),
&serde_json::json!({ "value": "Planned" })
);
}
#[test]
fn option_value_not_in_allowed_values_errors_with_field_and_options() {
let editmeta = meta_with_allowed(
"customfield_21051",
"Work Attribution",
"option",
&["Planned", "Unplanned"],
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Work Attribution".to_string(),
serde_yaml::Value::String("Plnned".to_string()),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Work Attribution"), "got: {msg}");
assert!(msg.contains("customfield_21051"), "got: {msg}");
assert!(
msg.contains("'Plnned' is not an allowed option"),
"got: {msg}"
);
assert!(msg.contains("Planned, Unplanned"), "got: {msg}");
}
#[test]
fn option_value_matching_is_case_sensitive() {
let editmeta = meta_with_allowed(
"customfield_21051",
"Work Attribution",
"option",
&["Planned"],
);
let mut scalars = BTreeMap::new();
scalars.insert(
"Work Attribution".to_string(),
serde_yaml::Value::String("planned".to_string()),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("not an allowed option"));
}
#[test]
fn option_field_without_allowed_values_passes_any_value_through() {
let editmeta = meta(&[("customfield_21051", "Work Attribution", "option", None)]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Work Attribution".to_string(),
serde_yaml::Value::String("Anything".to_string()),
);
let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
assert_eq!(
out.get("customfield_21051").unwrap(),
&serde_json::json!({ "value": "Anything" })
);
}
#[test]
fn array_option_element_not_in_allowed_values_errors() {
let editmeta =
meta_with_allowed("customfield_10004", "Teams", "array", &["backend", "auth"]);
let mut scalars = BTreeMap::new();
scalars.insert(
"Teams".to_string(),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("backend".to_string()),
serde_yaml::Value::String("frontend".to_string()),
]),
);
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("'frontend' is not an allowed option"),
"got: {msg}"
);
assert!(msg.contains("backend, auth"), "got: {msg}");
}
#[test]
fn option_field_rejects_non_scalar_value() {
let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
let mut mapping = serde_yaml::Mapping::new();
mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
let mut scalars = BTreeMap::new();
scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(format!("{err:#}").contains("expected a scalar string value"));
}
#[test]
fn section_with_stale_id_falls_back_to_name_lookup() {
let editmeta = meta(&[(
"customfield_NEW",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let sections = [CustomFieldSection {
name: "Acceptance Criteria".to_string(),
id: "customfield_OLD".to_string(),
body: "body".to_string(),
}];
let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
assert!(out.contains_key("customfield_NEW"));
assert!(!out.contains_key("customfield_OLD"));
}
#[test]
fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
let mut scalars = BTreeMap::new();
scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
assert!(err.to_string().contains("Unknown custom field"));
}
#[test]
fn parse_set_field_bare_string_value() {
let (name, value) = parse_set_field("Status=Open").unwrap();
assert_eq!(name, "Status");
assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
}
#[test]
fn parse_set_field_numeric_value_becomes_number() {
let (_name, value) = parse_set_field("Points=8").unwrap();
assert_eq!(value, serde_yaml::Value::Number(8.into()));
}
#[test]
fn parse_set_field_bool_value_becomes_bool() {
let (_name, value) = parse_set_field("Enabled=true").unwrap();
assert_eq!(value, serde_yaml::Value::Bool(true));
}
#[test]
fn parse_set_field_preserves_spaces_in_name() {
let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
assert_eq!(name, "Planned / Unplanned Work");
assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
}
#[test]
fn parse_set_field_equals_in_value_preserved() {
let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
assert_eq!(name, "Formula");
assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
}
#[test]
fn parse_set_field_requires_equals() {
let err = parse_set_field("just-a-name").unwrap_err();
assert!(err.to_string().contains("expected --set-field"));
}
#[test]
fn parse_set_field_empty_name_errors() {
let err = parse_set_field("=value").unwrap_err();
assert!(err.to_string().contains("non-empty name"));
}
#[test]
fn merge_set_field_overrides_cli_wins() {
let mut frontmatter = BTreeMap::new();
frontmatter.insert(
"Priority".to_string(),
serde_yaml::Value::String("Low".to_string()),
);
frontmatter.insert(
"Keep".to_string(),
serde_yaml::Value::String("from-fm".to_string()),
);
let overrides = vec![(
"Priority".to_string(),
serde_yaml::Value::String("High".to_string()),
)];
let merged = merge_set_field_overrides(frontmatter, overrides);
assert_eq!(
merged.get("Priority"),
Some(&serde_yaml::Value::String("High".to_string()))
);
assert_eq!(
merged.get("Keep"),
Some(&serde_yaml::Value::String("from-fm".to_string()))
);
}
#[test]
fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
let mut frontmatter = BTreeMap::new();
frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
let merged = merge_set_field_overrides(frontmatter, vec![]);
assert_eq!(merged.len(), 1);
assert_eq!(
merged.get("K"),
Some(&serde_yaml::Value::String("v".to_string()))
);
}
#[test]
fn section_prefers_tag_id_over_name_lookup() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let sections = [CustomFieldSection {
name: "Acceptance Criteria".to_string(),
id: "customfield_19300".to_string(),
body: "body".to_string(),
}];
let out = resolve_custom_fields(&BTreeMap::new(), §ions, &editmeta).unwrap();
assert!(out.contains_key("customfield_19300"));
}
#[test]
fn convert_textarea_string_value_converts_to_adf() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_19300".to_string(),
serde_json::Value::String("- one\n- two".to_string()),
);
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
let value = fields.get("customfield_19300").unwrap();
assert_eq!(value["type"], "doc");
assert_eq!(value["version"], 1);
assert!(value["content"].is_array());
}
#[test]
fn convert_textarea_object_value_passes_through() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let raw_adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]
});
let mut fields = BTreeMap::new();
fields.insert("customfield_19300".to_string(), raw_adf.clone());
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
assert_eq!(fields.get("customfield_19300").unwrap(), &raw_adf);
}
#[test]
fn convert_textarea_empty_string_clears_field() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_19300".to_string(),
serde_json::Value::String(String::new()),
);
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
assert_eq!(
fields.get("customfield_19300").unwrap(),
&serde_json::Value::Null
);
}
#[test]
fn convert_non_textarea_string_passes_through() {
let editmeta = meta(&[("customfield_10010", "Some Text", "string", None)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_10010".to_string(),
serde_json::Value::String("plain".to_string()),
);
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
assert_eq!(
fields.get("customfield_10010").unwrap(),
&serde_json::Value::String("plain".to_string())
);
}
#[test]
fn convert_unknown_field_passes_through() {
let editmeta = meta(&[("customfield_OTHER", "Other", "string", None)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_99999".to_string(),
serde_json::Value::String("- a".to_string()),
);
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
assert_eq!(
fields.get("customfield_99999").unwrap(),
&serde_json::Value::String("- a".to_string())
);
}
#[test]
fn convert_textarea_non_string_non_object_passes_through() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_19300".to_string(),
serde_json::Value::Number(42.into()),
);
convert_textarea_string_values(&mut fields, &editmeta).unwrap();
assert_eq!(
fields.get("customfield_19300").unwrap(),
&serde_json::Value::Number(42.into())
);
}
#[test]
fn convert_textarea_invalid_adf_nesting_errors() {
let editmeta = meta(&[(
"customfield_19300",
"Acceptance Criteria",
"string",
Some(CUSTOM_TEXTAREA),
)]);
let mut fields = BTreeMap::new();
fields.insert(
"customfield_19300".to_string(),
serde_json::Value::String(
":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::".to_string(),
),
);
let err = convert_textarea_string_values(&mut fields, &editmeta).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Acceptance Criteria"));
assert!(msg.contains("ADF nesting validation"));
assert!(msg.contains("`expand` cannot be a child of `panel`"));
}
}