1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::api::{Client, Projection};
5use crate::error::Result;
6use crate::fields::{same, usage};
7
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct ProjectSchema {
11 pub project: String,
12 pub fields: Vec<SchemaField>,
13}
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct SchemaField {
17 pub name: String,
18 pub aliases: Vec<String>,
19 pub type_id: String,
21 pub multi: bool,
22 pub can_be_empty: bool,
23 pub has_default: bool,
24 pub values: Vec<String>,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Kind {
30 Bundle,
31 State,
32 User,
33 Date,
34 DateTime,
35 Period,
36 Integer,
37 Float,
38 String,
39 Text,
40 Other,
41}
42
43impl SchemaField {
44 pub fn unknown(name: &str) -> Self {
46 Self {
47 name: name.to_string(),
48 aliases: Vec::new(),
49 type_id: String::new(),
50 multi: false,
51 can_be_empty: true,
52 has_default: true,
53 values: Vec::new(),
54 }
55 }
56
57 pub fn kind(&self) -> Kind {
58 match self.type_id.as_str() {
59 "enum" | "version" | "build" | "ownedField" | "group" => Kind::Bundle,
60 "state" => Kind::State,
61 "user" => Kind::User,
62 "date" => Kind::Date,
63 "date and time" => Kind::DateTime,
64 "period" => Kind::Period,
65 "integer" => Kind::Integer,
66 "float" => Kind::Float,
67 "string" => Kind::String,
68 "text" => Kind::Text,
69 _ => Kind::Other,
70 }
71 }
72
73 pub fn write_type(&self) -> Option<&'static str> {
75 let (single, multi) = match self.type_id.as_str() {
76 "enum" => (
77 "SingleEnumIssueCustomField",
78 Some("MultiEnumIssueCustomField"),
79 ),
80 "state" => ("StateIssueCustomField", None),
81 "user" => (
82 "SingleUserIssueCustomField",
83 Some("MultiUserIssueCustomField"),
84 ),
85 "version" => (
86 "SingleVersionIssueCustomField",
87 Some("MultiVersionIssueCustomField"),
88 ),
89 "build" => (
90 "SingleBuildIssueCustomField",
91 Some("MultiBuildIssueCustomField"),
92 ),
93 "ownedField" => (
94 "SingleOwnedIssueCustomField",
95 Some("MultiOwnedIssueCustomField"),
96 ),
97 "group" => (
98 "SingleGroupIssueCustomField",
99 Some("MultiGroupIssueCustomField"),
100 ),
101 "date" | "date and time" => ("DateIssueCustomField", None),
102 "period" => ("PeriodIssueCustomField", None),
103 "integer" | "float" | "string" => ("SimpleIssueCustomField", None),
104 "text" => ("TextIssueCustomField", None),
105 _ => return None,
106 };
107 if self.multi { multi } else { Some(single) }
108 }
109
110 pub fn matches(&self, name: &str) -> bool {
111 same(&self.name, name) || self.aliases.iter().any(|alias| same(alias, name))
112 }
113}
114
115impl ProjectSchema {
116 pub fn field(&self, name: &str) -> Option<&SchemaField> {
117 self.fields.iter().find(|field| field.matches(name))
118 }
119}
120
121const PROJECT_FIELD: &str = "canBeEmpty,defaultValues(name,login),field(name,aliases,fieldType(id,isMultiValue)),bundle(values(name,login,archived),aggregatedUsers(login))";
122
123#[derive(Deserialize)]
124#[serde(rename_all = "camelCase")]
125struct RawProjectField {
126 #[serde(default)]
127 can_be_empty: bool,
128 #[serde(default)]
129 default_values: Value,
130 field: RawField,
131 bundle: Option<RawBundle>,
132}
133
134impl Projection for RawProjectField {
135 const FIELDS: &'static str = PROJECT_FIELD;
136}
137
138#[derive(Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct RawField {
141 name: String,
142 #[serde(default)]
143 aliases: Value,
144 field_type: RawFieldType,
145}
146
147#[derive(Deserialize)]
148#[serde(rename_all = "camelCase")]
149struct RawFieldType {
150 id: String,
151 #[serde(default)]
152 is_multi_value: bool,
153}
154
155#[derive(Deserialize)]
156#[serde(rename_all = "camelCase")]
157struct RawBundle {
158 #[serde(default)]
159 values: Vec<RawValue>,
160 #[serde(default)]
161 aggregated_users: Vec<RawUser>,
162}
163
164#[derive(Deserialize)]
165struct RawValue {
166 name: Option<String>,
167 login: Option<String>,
168 #[serde(default)]
169 archived: bool,
170}
171
172#[derive(Deserialize)]
173struct RawUser {
174 login: String,
175}
176
177#[derive(Deserialize)]
178#[serde(rename_all = "camelCase")]
179struct IssueFields {
180 #[serde(default)]
181 custom_fields: Vec<IssueField>,
182}
183
184#[derive(Deserialize)]
185#[serde(rename_all = "camelCase")]
186struct IssueField {
187 project_custom_field: Option<RawProjectField>,
188}
189
190impl Projection for IssueFields {
191 const FIELDS: &'static str = "customFields(projectCustomField(canBeEmpty,defaultValues(name,login),field(name,aliases,fieldType(id,isMultiValue)),bundle(values(name,login,archived),aggregatedUsers(login))))";
192}
193
194impl From<RawProjectField> for SchemaField {
195 fn from(raw: RawProjectField) -> Self {
196 let id = raw.field.field_type.id;
197 let type_id = id
198 .split_once('[')
199 .map_or(id.as_str(), |(base, _)| base)
200 .to_string();
201 let values = match raw.bundle {
202 Some(bundle) if !bundle.aggregated_users.is_empty() => bundle
203 .aggregated_users
204 .into_iter()
205 .map(|user| user.login)
206 .collect(),
207 Some(bundle) => bundle
208 .values
209 .into_iter()
210 .filter(|value| !value.archived)
211 .filter_map(|value| {
212 if type_id == "user" {
213 value.login.or(value.name)
214 } else {
215 value.name.or(value.login)
216 }
217 })
218 .collect(),
219 None => Vec::new(),
220 };
221 let aliases = match raw.field.aliases {
222 Value::String(text) => text
223 .split(',')
224 .map(str::trim)
225 .filter(|alias| !alias.is_empty())
226 .map(str::to_string)
227 .collect(),
228 Value::Array(items) => items
229 .into_iter()
230 .filter_map(|item| item.as_str().map(str::to_string))
231 .collect(),
232 _ => Vec::new(),
233 };
234 let has_default = match raw.default_values {
235 Value::Array(items) => !items.is_empty(),
236 Value::Null => false,
237 _ => true,
238 };
239 Self {
240 name: raw.field.name,
241 aliases,
242 type_id,
243 multi: raw.field.field_type.is_multi_value,
244 can_be_empty: raw.can_be_empty,
245 has_default,
246 values,
247 }
248 }
249}
250
251pub async fn fetch(client: &Client, project: &str) -> Result<ProjectSchema> {
254 check_short_name(project)?;
255 let fields: Vec<RawProjectField> = client
256 .get(
257 &format!("admin/projects/{project}/customFields"),
258 &[("$top", "500")],
259 )
260 .await?;
261 let fields: Vec<SchemaField> = if fields.is_empty() {
262 let query = format!("project: {{{project}}}");
263 let issues: Vec<IssueFields> = client
264 .get("issues", &[("query", query.as_str()), ("$top", "1")])
265 .await?;
266 issues
267 .into_iter()
268 .next()
269 .map(|issue| {
270 issue
271 .custom_fields
272 .into_iter()
273 .filter_map(|field| field.project_custom_field)
274 .map(SchemaField::from)
275 .collect()
276 })
277 .unwrap_or_default()
278 } else {
279 fields.into_iter().map(SchemaField::from).collect()
280 };
281 Ok(ProjectSchema {
282 project: project.to_string(),
283 fields,
284 })
285}
286
287pub fn check_short_name(project: &str) -> Result<()> {
289 if !project.is_empty() && project.chars().all(|c| c.is_alphanumeric() || c == '_') {
290 Ok(())
291 } else {
292 Err(usage(format!("'{project}' is not a project short name")))
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use serde_json::json;
299 use wiremock::matchers::{method, path, query_param};
300 use wiremock::{Mock, MockServer, ResponseTemplate};
301
302 use super::*;
303 use crate::api::test_client;
304
305 fn project_fields() -> serde_json::Value {
306 json!([
307 {"canBeEmpty": false, "defaultValues": [{"name": "Normal"}],
308 "field": {"name": "Priority", "aliases": "prio, p", "fieldType": {"id": "enum[1]", "isMultiValue": false}},
309 "bundle": {"values": [{"name": "Normal", "archived": false}, {"name": "Major", "archived": false}, {"name": "Old", "archived": true}]}},
310 {"canBeEmpty": true, "defaultValues": [],
311 "field": {"name": "Assignee", "aliases": "", "fieldType": {"id": "user[1]", "isMultiValue": false}},
312 "bundle": {"aggregatedUsers": [{"login": "jane"}, {"login": "bob"}]}},
313 {"canBeEmpty": true, "defaultValues": [],
314 "field": {"name": "Fix versions", "fieldType": {"id": "version[*]", "isMultiValue": true}},
315 "bundle": {"values": [{"name": "1.0", "archived": false}]}},
316 {"canBeEmpty": true,
317 "field": {"name": "Due Date", "fieldType": {"id": "date", "isMultiValue": false}}}
318 ])
319 }
320
321 fn expected() -> Vec<SchemaField> {
322 vec![
323 SchemaField {
324 name: "Priority".into(),
325 aliases: vec!["prio".into(), "p".into()],
326 type_id: "enum".into(),
327 multi: false,
328 can_be_empty: false,
329 has_default: true,
330 values: vec!["Normal".into(), "Major".into()],
331 },
332 SchemaField {
333 name: "Assignee".into(),
334 aliases: vec![],
335 type_id: "user".into(),
336 multi: false,
337 can_be_empty: true,
338 has_default: false,
339 values: vec!["jane".into(), "bob".into()],
340 },
341 SchemaField {
342 name: "Fix versions".into(),
343 aliases: vec![],
344 type_id: "version".into(),
345 multi: true,
346 can_be_empty: true,
347 has_default: false,
348 values: vec!["1.0".into()],
349 },
350 SchemaField {
351 name: "Due Date".into(),
352 aliases: vec![],
353 type_id: "date".into(),
354 multi: false,
355 can_be_empty: true,
356 has_default: false,
357 values: vec![],
358 },
359 ]
360 }
361
362 #[tokio::test]
363 async fn the_schema_comes_from_the_project() {
364 let server = MockServer::start().await;
365 Mock::given(method("GET"))
366 .and(path("/api/admin/projects/WEB/customFields"))
367 .respond_with(ResponseTemplate::new(200).set_body_json(project_fields()))
368 .expect(1)
369 .mount(&server)
370 .await;
371 let schema = fetch(&test_client(&server.uri()), "WEB").await.unwrap();
372 assert_eq!(schema.project, "WEB");
373 assert_eq!(schema.fields, expected());
374 }
375
376 #[tokio::test]
377 async fn an_empty_project_list_falls_back_to_an_issue() {
378 let server = MockServer::start().await;
379 Mock::given(method("GET"))
380 .and(path("/api/admin/projects/WEB/customFields"))
381 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
382 .mount(&server)
383 .await;
384 let issue_fields: Vec<serde_json::Value> = project_fields()
385 .as_array()
386 .unwrap()
387 .iter()
388 .map(|field| json!({"projectCustomField": field}))
389 .collect();
390 Mock::given(method("GET"))
391 .and(path("/api/issues"))
392 .and(query_param("query", "project: {WEB}"))
393 .respond_with(
394 ResponseTemplate::new(200).set_body_json(json!([{"customFields": issue_fields}])),
395 )
396 .expect(1)
397 .mount(&server)
398 .await;
399 let schema = fetch(&test_client(&server.uri()), "WEB").await.unwrap();
400 assert_eq!(schema.fields, expected());
401 }
402
403 #[test]
404 fn kinds_and_write_types_follow_the_field_type() {
405 let mut field = expected().remove(2);
406 assert_eq!(field.kind(), Kind::Bundle);
407 assert_eq!(field.write_type(), Some("MultiVersionIssueCustomField"));
408 field.multi = false;
409 assert_eq!(field.write_type(), Some("SingleVersionIssueCustomField"));
410 for (type_id, kind, write_type) in [
411 ("state", Kind::State, Some("StateIssueCustomField")),
412 (
413 "date and time",
414 Kind::DateTime,
415 Some("DateIssueCustomField"),
416 ),
417 ("period", Kind::Period, Some("PeriodIssueCustomField")),
418 ("integer", Kind::Integer, Some("SimpleIssueCustomField")),
419 ("text", Kind::Text, Some("TextIssueCustomField")),
420 ("", Kind::Other, None),
421 ] {
422 field.type_id = type_id.into();
423 assert_eq!(
424 (field.kind(), field.write_type()),
425 (kind, write_type),
426 "{type_id}"
427 );
428 }
429 }
430
431 #[test]
432 fn fields_are_found_by_name_or_alias() {
433 let schema = ProjectSchema {
434 project: "WEB".into(),
435 fields: expected(),
436 };
437 assert_eq!(
438 schema.field("PRIO").map(|field| field.name.as_str()),
439 Some("Priority")
440 );
441 assert_eq!(
442 schema
443 .field("fix VERSIONS")
444 .map(|field| field.name.as_str()),
445 Some("Fix versions")
446 );
447 assert!(schema.field("Type").is_none());
448 }
449
450 #[test]
451 fn only_short_names_are_accepted_in_paths() {
452 assert!(check_short_name("WEB_2").is_ok());
453 assert!(check_short_name("A/B").is_err());
454 assert!(check_short_name("").is_err());
455 }
456}