Skip to main content

yt/api/
custom_fields.rs

1use serde::Deserialize;
2use serde_json::Value;
3
4/// One selector for every custom field type; YouTrack drops the subfields a type does not have.
5pub 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/// Just enough of the field definition to tell a date from a date and time.
20#[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
170/// The issue's state: the value of the first field whose values know whether they resolve the issue.
171pub 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}