1use serde::Deserialize;
2use serde_json::Value;
3
4pub const FIELDS: &str = "name,$type,projectCustomField(field(fieldType(id))),value(name,login,fullName,isResolved,minutes,presentation,text,$type)";
6
7#[derive(Clone, Debug, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct IssueCustomField {
10 pub name: String,
11 #[serde(rename = "$type")]
12 pub kind: String,
13 #[serde(default)]
14 pub value: Value,
15 #[serde(default)]
16 pub project_custom_field: Option<ProjectFieldRef>,
17}
18
19#[derive(Clone, Debug, Default, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct ProjectFieldRef {
23 pub field: Option<FieldRef>,
24}
25
26#[derive(Clone, Debug, Default, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct FieldRef {
29 pub field_type: Option<FieldTypeRef>,
30}
31
32#[derive(Clone, Debug, Default, Deserialize)]
33pub struct FieldTypeRef {
34 pub id: String,
35}
36
37#[derive(Clone, Debug, PartialEq)]
38pub enum FieldValue {
39 Empty,
40 Bundle {
41 name: String,
42 is_resolved: Option<bool>,
43 },
44 Bundles(Vec<String>),
45 User {
46 login: String,
47 full_name: Option<String>,
48 },
49 Users(Vec<String>),
50 Period {
51 minutes: i64,
52 presentation: String,
53 },
54 Date(i64),
55 Text(String),
56 Scalar(Value),
57 Unknown(Value),
58}
59
60impl IssueCustomField {
61 pub fn is_date_time(&self) -> bool {
62 self.project_custom_field
63 .as_ref()
64 .and_then(|definition| definition.field.as_ref())
65 .and_then(|field| field.field_type.as_ref())
66 .is_some_and(|field_type| field_type.id == "date and time")
67 }
68
69 pub fn typed(&self) -> FieldValue {
70 let value = &self.value;
71 if value.is_null() {
72 return FieldValue::Empty;
73 }
74 let unknown = || FieldValue::Unknown(value.clone());
75 let text = |key: &str| value.get(key).and_then(Value::as_str).map(str::to_string);
76 match self.kind.as_str() {
77 "SingleEnumIssueCustomField"
78 | "StateIssueCustomField"
79 | "StateMachineIssueCustomField"
80 | "SingleVersionIssueCustomField"
81 | "SingleBuildIssueCustomField"
82 | "SingleOwnedIssueCustomField"
83 | "SingleGroupIssueCustomField" => match text("name") {
84 Some(name) => FieldValue::Bundle {
85 name,
86 is_resolved: value.get("isResolved").and_then(Value::as_bool),
87 },
88 None => unknown(),
89 },
90 "MultiEnumIssueCustomField"
91 | "MultiVersionIssueCustomField"
92 | "MultiBuildIssueCustomField"
93 | "MultiOwnedIssueCustomField"
94 | "MultiGroupIssueCustomField" => list(value, "name")
95 .map_or_else(unknown, |names| non_empty(names, FieldValue::Bundles)),
96 "SingleUserIssueCustomField" => match text("login") {
97 Some(login) => FieldValue::User {
98 login,
99 full_name: text("fullName"),
100 },
101 None => unknown(),
102 },
103 "MultiUserIssueCustomField" => list(value, "login")
104 .map_or_else(unknown, |logins| non_empty(logins, FieldValue::Users)),
105 "PeriodIssueCustomField" => match (
106 value.get("minutes").and_then(Value::as_i64),
107 text("presentation"),
108 ) {
109 (Some(minutes), Some(presentation)) => FieldValue::Period {
110 minutes,
111 presentation,
112 },
113 _ => unknown(),
114 },
115 "DateIssueCustomField" => value.as_i64().map_or_else(unknown, FieldValue::Date),
116 "TextIssueCustomField" => text("text").map_or_else(unknown, FieldValue::Text),
117 "SimpleIssueCustomField" => FieldValue::Scalar(value.clone()),
118 _ => unknown(),
119 }
120 }
121
122 pub fn display(&self) -> String {
123 match self.typed() {
124 FieldValue::Empty => String::new(),
125 FieldValue::Bundle { name, .. } => name,
126 FieldValue::Bundles(names) | FieldValue::Users(names) => names.join(", "),
127 FieldValue::User { login, .. } => login,
128 FieldValue::Period { presentation, .. } => presentation,
129 FieldValue::Date(millis) if self.is_date_time() => crate::time::local(millis),
130 FieldValue::Date(millis) => crate::time::date(millis),
131 FieldValue::Text(text) => text,
132 FieldValue::Scalar(Value::String(text)) => text,
133 FieldValue::Scalar(other) | FieldValue::Unknown(other) => other.to_string(),
134 }
135 }
136
137 pub fn json(&self) -> Value {
138 match self.typed() {
139 FieldValue::Empty => Value::Null,
140 FieldValue::Bundle { name, .. } => Value::String(name),
141 FieldValue::Bundles(names) | FieldValue::Users(names) => Value::from(names),
142 FieldValue::User { login, .. } => Value::String(login),
143 FieldValue::Period { presentation, .. } => Value::String(presentation),
144 FieldValue::Date(millis) if self.is_date_time() => {
145 Value::String(crate::time::utc(millis))
146 }
147 FieldValue::Date(millis) => Value::String(crate::time::date(millis)),
148 FieldValue::Text(text) => Value::String(text),
149 FieldValue::Scalar(value) | FieldValue::Unknown(value) => value,
150 }
151 }
152}
153
154fn list(value: &Value, key: &str) -> Option<Vec<String>> {
155 value
156 .as_array()?
157 .iter()
158 .map(|item| item.get(key).and_then(Value::as_str).map(str::to_string))
159 .collect()
160}
161
162fn non_empty(items: Vec<String>, wrap: fn(Vec<String>) -> FieldValue) -> FieldValue {
163 if items.is_empty() {
164 FieldValue::Empty
165 } else {
166 wrap(items)
167 }
168}
169
170pub fn state_of(fields: &[IssueCustomField]) -> Option<String> {
172 fields.iter().find_map(|field| match field.typed() {
173 FieldValue::Bundle {
174 name,
175 is_resolved: Some(_),
176 } => Some(name),
177 _ => None,
178 })
179}
180
181#[cfg(test)]
182mod tests {
183 use serde_json::json;
184
185 use super::*;
186
187 fn field(kind: &str, value: Value) -> IssueCustomField {
188 IssueCustomField {
189 name: "F".into(),
190 kind: kind.into(),
191 value,
192 project_custom_field: None,
193 }
194 }
195
196 #[test]
197 fn state_values_keep_is_resolved() {
198 let state = field(
199 "StateMachineIssueCustomField",
200 json!({"name": "In Progress", "isResolved": false, "$type": "StateBundleElement"}),
201 );
202 assert_eq!(
203 state.typed(),
204 FieldValue::Bundle {
205 name: "In Progress".into(),
206 is_resolved: Some(false)
207 }
208 );
209 assert_eq!(state.display(), "In Progress");
210 assert_eq!(state.json(), json!("In Progress"));
211 }
212
213 #[test]
214 fn multi_values_and_empty_ones() {
215 let versions = field(
216 "MultiVersionIssueCustomField",
217 json!([{"name": "1.0"}, {"name": "1.1"}]),
218 );
219 assert_eq!(versions.display(), "1.0, 1.1");
220 assert_eq!(versions.json(), json!(["1.0", "1.1"]));
221 assert_eq!(
222 field("MultiVersionIssueCustomField", json!([])).typed(),
223 FieldValue::Empty
224 );
225 assert_eq!(
226 field("SingleEnumIssueCustomField", Value::Null).display(),
227 ""
228 );
229 }
230
231 #[test]
232 fn users_periods_dates_and_text() {
233 assert_eq!(
234 field(
235 "SingleUserIssueCustomField",
236 json!({"login": "jane", "fullName": "Jane Doe"})
237 )
238 .display(),
239 "jane"
240 );
241 assert_eq!(
242 field(
243 "MultiUserIssueCustomField",
244 json!([{"login": "a"}, {"login": "b"}])
245 )
246 .display(),
247 "a, b"
248 );
249 assert_eq!(
250 field(
251 "PeriodIssueCustomField",
252 json!({"minutes": 150, "presentation": "2h 30m"})
253 )
254 .display(),
255 "2h 30m"
256 );
257 assert_eq!(
258 field("DateIssueCustomField", json!(1_791_590_400_000_i64)).display(),
259 "2026-10-10"
260 );
261 assert_eq!(
262 field("TextIssueCustomField", json!({"text": "notes"})).display(),
263 "notes"
264 );
265 assert_eq!(field("SimpleIssueCustomField", json!(3)).display(), "3");
266 assert_eq!(
267 field("SimpleIssueCustomField", json!("a@b.c")).display(),
268 "a@b.c"
269 );
270 }
271
272 #[test]
273 fn unknown_types_keep_their_raw_value() {
274 let odd = field("FutureIssueCustomField", json!({"weird": true}));
275 assert_eq!(odd.typed(), FieldValue::Unknown(json!({"weird": true})));
276 assert_eq!(odd.json(), json!({"weird": true}));
277 }
278
279 #[test]
280 fn mixed_field_lists_always_decode() {
281 let fields: Vec<IssueCustomField> = serde_json::from_value(json!([
282 {"name": "Priority", "$type": "SingleEnumIssueCustomField", "value": {"name": "Normal", "$type": "EnumBundleElement"}},
283 {"name": "Assignee", "$type": "SingleUserIssueCustomField", "value": null},
284 {"name": "Estimation", "$type": "PeriodIssueCustomField", "value": {"minutes": 60, "presentation": "1h"}},
285 {"name": "Due", "$type": "DateIssueCustomField"}
286 ]))
287 .unwrap();
288 assert_eq!(fields.len(), 4);
289 assert_eq!(fields[3].typed(), FieldValue::Empty);
290 }
291
292 #[test]
293 fn date_and_time_fields_keep_their_time() {
294 let mut date = field("DateIssueCustomField", json!(1_791_633_600_000_i64));
295 assert_eq!(date.json(), json!("2026-10-10"));
296 date.project_custom_field =
297 serde_json::from_value(json!({"field": {"fieldType": {"id": "date and time"}}}))
298 .unwrap();
299 assert!(date.is_date_time());
300 assert_eq!(date.json(), json!("2026-10-10T12:00:00Z"));
301 assert_eq!(date.display(), crate::time::local(1_791_633_600_000));
302 }
303}