Skip to main content

jira_cli/api/client/
metadata.rs

1//! Project-scoped metadata and conservative normalization for issue writes.
2
3use super::{ApiError, JiraClient, validate_issue_key};
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::collections::BTreeMap;
7
8mod type_change;
9mod validation;
10use validation::{normalize_named_arrays, validate_required};
11
12pub(super) type Fields = BTreeMap<String, Value>;
13pub(super) const EPIC_LINK_SCHEMA: &str = "com.pyxis.greenhopper.jira:gh-epic-link";
14
15#[derive(Clone, Debug, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub(super) struct IssueType {
18    #[serde(default)]
19    id: String,
20    name: String,
21    /// As reported: a missing flag is unknown, not false.
22    subtask: Option<bool>,
23    hierarchy_level: Option<i64>,
24    fields: Option<Fields>,
25}
26
27impl IssueType {
28    /// Explicit metadata decides; the name is a fallback for metadata that
29    /// omits the subtask flag.
30    fn is_subtask(&self) -> bool {
31        self.hierarchy_level == Some(-1)
32            || self.subtask.unwrap_or_else(|| {
33                matches!(
34                    self.name.to_ascii_lowercase().as_str(),
35                    "subtask" | "sub-task"
36                )
37            })
38    }
39    fn is_epic(&self) -> bool {
40        self.hierarchy_level == Some(1) || self.name.eq_ignore_ascii_case("Epic")
41    }
42
43    fn can_belong_to_epic(&self) -> bool {
44        !self.is_subtask() && self.hierarchy_level.is_none_or(|level| level == 0) && !self.is_epic()
45    }
46}
47
48pub(super) struct CreateMetadata {
49    issue_type: IssueType,
50    pub(super) fields: Option<Fields>,
51    subtask_types: Vec<Value>,
52}
53
54impl JiraClient {
55    // Unsupported endpoints are optional. Authentication, rate limits, network
56    // failures and server errors must not be mistaken for absent metadata.
57    async fn optional_metadata(&self, path: &str) -> Result<Option<Value>, ApiError> {
58        match self.get(path).await {
59            Ok(value) => Ok(Some(value)),
60            Err(ApiError::NotFound(_))
61            | Err(ApiError::Api {
62                status: 405 | 410 | 501,
63                ..
64            }) => Ok(None),
65            Err(err) => Err(err),
66        }
67    }
68
69    /// Cloud calls its arrays `issueTypes`/`fields`; DC uses `values`.
70    async fn metadata_pages(
71        &self,
72        path: &str,
73        array: &str,
74    ) -> Result<Option<Vec<Value>>, ApiError> {
75        let mut values = Vec::new();
76        let mut start = 0;
77        loop {
78            let Some(page) = self
79                .optional_metadata(&format!("{path}?startAt={start}&maxResults=100"))
80                .await?
81            else {
82                if start == 0 {
83                    return Ok(None);
84                }
85                return Err(ApiError::Other(
86                    "Jira metadata pagination became unavailable".into(),
87                ));
88            };
89            let items = page
90                .get(array)
91                .or_else(|| page.get("values"))
92                .and_then(Value::as_array)
93                .ok_or_else(|| {
94                    ApiError::Other("Invalid Jira metadata page: missing items".into())
95                })?;
96            let next = start + items.len();
97            if page["startAt"]
98                .as_u64()
99                .is_some_and(|n| n as usize != start)
100            {
101                return Err(ApiError::Other(
102                    "Jira metadata pagination did not advance".into(),
103                ));
104            }
105            values.extend(items.iter().cloned());
106            let total = page["total"].as_u64().map(|n| n as usize);
107            if page["isLast"] == true || total.is_some_and(|n| next >= n) {
108                return Ok(Some(values));
109            }
110            if items.is_empty() {
111                return Err(ApiError::Other(
112                    "Jira metadata pagination did not advance".into(),
113                ));
114            }
115            if total.is_none() && page["isLast"].as_bool().is_none() {
116                return Err(ApiError::Other(
117                    "Invalid Jira metadata page: missing pagination".into(),
118                ));
119            }
120            start = next;
121        }
122    }
123
124    async fn legacy_create_metadata(
125        &self,
126        project: &str,
127        include_fields: bool,
128    ) -> Result<Option<Vec<IssueType>>, ApiError> {
129        let parameter = if project.bytes().all(|b| b.is_ascii_digit()) {
130            "projectIds"
131        } else {
132            "projectKeys"
133        };
134        let project_query: String =
135            reqwest::Url::parse_with_params("http://localhost", &[(parameter, project)])
136                .expect("static URL")
137                .query()
138                .unwrap()
139                .into();
140        let expand = if include_fields {
141            "projects.issuetypes.fields"
142        } else {
143            "projects.issuetypes"
144        };
145        let Some(meta) = self
146            .optional_metadata(&format!("issue/createmeta?{project_query}&expand={expand}"))
147            .await?
148        else {
149            return Ok(None);
150        };
151        let projects = meta["projects"].as_array().ok_or_else(|| {
152            ApiError::Other("Invalid Jira create metadata: missing projects".into())
153        })?;
154        let types = projects
155            .iter()
156            .find(|p| {
157                p["key"]
158                    .as_str()
159                    .is_some_and(|key| key.eq_ignore_ascii_case(project))
160                    || p["id"].as_str() == Some(project)
161            })
162            .map(|p| p["issuetypes"].clone())
163            .ok_or_else(|| ApiError::NotFound(format!("Project {project:?} is unavailable or you lack permission to create issues in it")))?;
164        decode(types).map(Some)
165    }
166
167    async fn create_types(
168        &self,
169        project: &str,
170        include_fields: bool,
171    ) -> Result<Option<(Vec<IssueType>, bool)>, ApiError> {
172        if project.trim().is_empty() {
173            return Err(ApiError::InvalidInput("Project must not be empty".into()));
174        }
175        let path = format!("issue/createmeta/{}/issuetypes", encode_segment(project));
176        let (types, legacy): (Vec<IssueType>, bool) =
177            match self.metadata_pages(&path, "issueTypes").await? {
178                Some(types) => (decode(json!(types))?, false),
179                None => match self.legacy_create_metadata(project, include_fields).await? {
180                    Some(types) => (types, true),
181                    None => return Ok(None),
182                },
183            };
184        if types.is_empty() {
185            return Err(ApiError::NotFound(format!(
186                "No creatable issue types in project {project:?}; check the project and your create permission"
187            )));
188        }
189        Ok(Some((types, legacy)))
190    }
191
192    pub(super) async fn create_metadata(
193        &self,
194        project: &str,
195        input: &str,
196    ) -> Result<Option<CreateMetadata>, ApiError> {
197        let Some((types, legacy)) = self.create_types(project, true).await? else {
198            return Ok(None);
199        };
200        self.select_create_metadata(project, input, &types, legacy)
201            .await
202            .map(Some)
203    }
204
205    async fn select_create_metadata(
206        &self,
207        project: &str,
208        input: &str,
209        types: &[IssueType],
210        legacy: bool,
211    ) -> Result<CreateMetadata, ApiError> {
212        let path = format!("issue/createmeta/{}/issuetypes", encode_segment(project));
213        let options = types
214            .iter()
215            .map(|t| json!({"id": t.id, "name": t.name}))
216            .collect::<Vec<_>>();
217        let selected = match_option("issue type", input, &options, OptionMatch::NameOrId)?;
218        let subtask_types = types
219            .iter()
220            .filter(|t| t.is_subtask())
221            .map(|t| json!({"id": t.id, "name": t.name}))
222            .collect();
223        let issue_type = types[selected].clone();
224        let fields = if let Some(fields) = &issue_type.fields {
225            Some(fields.clone())
226        } else if legacy {
227            None
228        } else {
229            match self
230                .metadata_pages(
231                    &format!("{path}/{}", encode_segment(&issue_type.id)),
232                    "fields",
233                )
234                .await?
235            {
236                Some(fields) => Some(
237                    fields
238                        .into_iter()
239                        .map(|field| {
240                            let key = field["fieldId"]
241                                .as_str()
242                                .or_else(|| field["key"].as_str())
243                                .ok_or_else(|| {
244                                    ApiError::Other(
245                                        "Invalid Jira field metadata: missing field ID".into(),
246                                    )
247                                })?;
248                            Ok((key.to_owned(), field))
249                        })
250                        .collect::<Result<_, ApiError>>()?,
251                ),
252                None => {
253                    // The modern type response already established this project
254                    // and type. Legacy omission only means fields are unavailable.
255                    let fallback = match self.legacy_create_metadata(project, true).await {
256                        Ok(types) => types,
257                        Err(ApiError::NotFound(_)) => None,
258                        Err(error) => return Err(error),
259                    };
260                    fallback
261                        .and_then(|types| types.into_iter().find(|t| t.id == issue_type.id))
262                        .and_then(|t| t.fields)
263                }
264            }
265        };
266        Ok(CreateMetadata {
267            issue_type,
268            fields,
269            subtask_types,
270        })
271    }
272
273    /// Discover the create screen, retaining instance-specific field definitions.
274    /// Omitting the type lists available types without selecting a default.
275    pub async fn issue_create_metadata(
276        &self,
277        project: &str,
278        input: Option<&str>,
279    ) -> Result<Value, ApiError> {
280        let Some((types, legacy)) = self.create_types(project, input.is_some()).await? else {
281            self.get_project(project).await?;
282            return Err(ApiError::WithDetails {
283                source: Box::new(ApiError::NotFound("Create metadata is unavailable on this Jira instance; use `jira fields list` for global field discovery".into())),
284                details: json!({"reason":"unsupported", "project":project, "hint":"jira fields list"}),
285            });
286        };
287        let selected = match input {
288            Some(input) => Some(
289                self.select_create_metadata(project, input, &types, legacy)
290                    .await?,
291            ),
292            None => None,
293        };
294        let mut warnings = Vec::new();
295        let fields = selected
296            .as_ref()
297            .and_then(|m| m.fields.as_ref())
298            .map(|fields| {
299                fields
300                    .iter()
301                    .map(|(id, definition)| {
302                        let mut definition = definition.clone();
303                        if let Some(object) = definition.as_object_mut() {
304                            object.entry("allowedValues").or_insert(Value::Null);
305                            object.entry("defaultValue").or_insert(Value::Null);
306                        }
307                        (id.clone(), definition)
308                    })
309                    .collect::<Fields>()
310            });
311        if selected.is_some() && fields.is_none() {
312            warnings.push("Field metadata is unavailable: required fields, defaults, allowed values, and epic support could not be discovered.");
313        }
314        let epic = selected.as_ref().map(|m| {
315            let candidates = m
316                .fields
317                .as_ref()
318                .map(|fields| epic_candidates(fields, self.api_version))
319                .unwrap_or_default();
320            let status = if !m.issue_type.can_belong_to_epic() {
321                "not_applicable"
322            } else {
323                match candidates.len() {
324                    1 => "available",
325                    0 => "unavailable",
326                    _ => "ambiguous",
327                }
328            };
329            let field = if status == "available" {
330                Some(candidates[0].as_str())
331            } else {
332                None
333            };
334            let mechanism = field.map(|f| if f == "parent" { "parent" } else { "epic_link" });
335            json!({"status":status, "field":field, "mechanism":mechanism, "candidates":candidates})
336        });
337        Ok(
338            json!({"project":project, "issueTypes":types.iter().map(type_json).collect::<Vec<_>>(),
339            "issueType":selected.as_ref().map(|m| type_json(&m.issue_type)), "fields":fields, "epic":epic, "warnings":warnings}),
340        )
341    }
342
343    pub(super) async fn issue_type_for_link(&self, key: &str) -> Result<IssueType, ApiError> {
344        validate_issue_key(key)?;
345        let issue: Value = self.get(&format!("issue/{key}?fields=issuetype")).await?;
346        decode(issue["fields"]["issuetype"].clone())
347    }
348
349    async fn epic_field(&self, fields: Option<&Fields>) -> Result<String, ApiError> {
350        if let Some(fields) = fields {
351            if let Some(id) = choose_epic_field(epic_candidates(fields, self.api_version))? {
352                return Ok(id);
353            }
354        } else if self.api_version >= 3 {
355            return Ok("parent".into());
356        } else {
357            let fields = self
358                .list_fields()
359                .await?
360                .into_iter()
361                .map(|field| {
362                    (
363                        field.id.clone(),
364                        serde_json::to_value(field).expect("field serializes"),
365                    )
366                })
367                .collect();
368            if let Some(id) = find_epic_field(&fields)? {
369                return Ok(id);
370            }
371        }
372        Err(ApiError::InvalidInput(
373            "Cannot resolve epic linkage: neither Epic Link nor an epic parent is available in this issue's metadata. Enable Epic Link on the create/edit screen or inspect `jira fields list` and use --field <ID>=<EPIC>.".into()
374        ))
375    }
376
377    async fn set_epic(
378        &self,
379        fields: &mut Value,
380        meta: Option<&Fields>,
381        issue_type: &IssueType,
382        epic: &str,
383    ) -> Result<(), ApiError> {
384        if !issue_type.can_belong_to_epic() {
385            return Err(ApiError::InvalidInput(format!(
386                "Issue type {:?} cannot be added to an Epic; use --epic {epic} with a standard issue type such as Story or Task. For a subtask, use --parent <STORY-OR-TASK>.",
387                issue_type.name
388            )));
389        }
390        let field = self.epic_field(meta).await?;
391        validate_epic_override(fields, meta, &field)?;
392        fields[&field] = if field == "parent" {
393            json!({"key": epic})
394        } else {
395            json!(epic)
396        };
397        Ok(())
398    }
399
400    pub(super) async fn prepare_create(
401        &self,
402        fields: &mut Value,
403        draft: &super::IssueDraft<'_>,
404        custom: &[(String, Value)],
405    ) -> Result<Option<CreateMetadata>, ApiError> {
406        if draft.parent.is_some() && draft.epic.is_some() {
407            return Err(ApiError::InvalidInput(
408                "--parent and --epic cannot be used together".into(),
409            ));
410        }
411        if draft.parent.is_some() && fields.get("parent").is_some() {
412            return Err(ApiError::InvalidInput(
413                "--parent conflicts with --field parent; choose one source".into(),
414            ));
415        }
416        let input = fields["issuetype"]["id"]
417            .as_str()
418            .or_else(|| fields["issuetype"]["name"].as_str())
419            .ok_or_else(|| ApiError::InvalidInput("issuetype requires a name or ID".into()))?
420            .to_owned();
421        let project = fields["project"]["key"]
422            .as_str()
423            .or_else(|| fields["project"]["id"].as_str())
424            .ok_or_else(|| ApiError::InvalidInput("project requires a key or ID".into()))?;
425        let meta = self.create_metadata(project, &input).await?;
426        let fallback = IssueType {
427            id: String::new(),
428            name: input,
429            subtask: None,
430            hierarchy_level: None,
431            fields: None,
432        };
433        let issue_type = meta.as_ref().map(|m| &m.issue_type).unwrap_or(&fallback);
434        if meta.is_some() {
435            fields["issuetype"] = named_id(&issue_type.id, &issue_type.name);
436        }
437        let field_meta = meta.as_ref().and_then(|m| m.fields.as_ref());
438        if let Some(priority) = draft
439            .priority
440            .filter(|_| !custom.iter().any(|(key, _)| key == "priority"))
441        {
442            normalize_priority(fields, field_meta, priority)?;
443        }
444        normalize_named_arrays(fields, field_meta, custom)?;
445        if let Some(key) = draft.epic.or(draft.parent) {
446            let target = self.issue_type_for_link(key).await?;
447            if draft.epic.is_some() && !target.is_epic() {
448                return Err(ApiError::InvalidInput(format!(
449                    "--epic {key} targets issue type {:?}, not an Epic",
450                    target.name
451                )));
452            }
453            if target.is_epic() {
454                // The ordinary parent is added by this method only after we
455                // know the target type, so an explicit --field stays detectable.
456                self.set_epic(fields, field_meta, issue_type, key).await?;
457            } else if fields.get("parent").is_none() {
458                if let Some(meta) = &meta {
459                    validate_parent_type(issue_type, &target, key, &meta.subtask_types)?;
460                }
461                fields["parent"] = json!({"key": key});
462            }
463        }
464        if meta.is_some()
465            && issue_type.is_subtask()
466            && fields.get("parent").is_none_or(Value::is_null)
467        {
468            return Err(ApiError::InvalidInput(format!(
469                "Issue type {:?} requires --parent <STORY-OR-TASK>",
470                issue_type.name
471            )));
472        }
473        validate_required(fields, field_meta, true)?;
474        Ok(meta)
475    }
476
477    pub(super) async fn prepare_update(
478        &self,
479        key: &str,
480        fields: &mut Value,
481        update: &super::IssueUpdate<'_>,
482        custom: &[(String, Value)],
483    ) -> Result<Option<Fields>, ApiError> {
484        let priority = update
485            .priority
486            .filter(|_| !custom.iter().any(|(key, _)| key == "priority"));
487        if update.epic.is_some() && update.clear_epic {
488            return Err(ApiError::InvalidInput(
489                "--epic and --clear-epic cannot be used together".into(),
490            ));
491        }
492        let meta = self
493            .optional_metadata(&format!("issue/{key}/editmeta"))
494            .await?
495            .map(|meta| decode::<Fields>(meta["fields"].clone()))
496            .transpose()?;
497        if let Some(priority) = priority {
498            normalize_priority(fields, meta.as_ref(), priority)?;
499        }
500        normalize_named_arrays(fields, meta.as_ref(), custom)?;
501        // The epic checks below judge the type the issue will have after this
502        // write, since a type change and epic linkage travel in one request.
503        let target_type = match update.issue_type {
504            Some(input) => {
505                if custom.iter().any(|(key, _)| key == "issuetype") {
506                    return Err(ApiError::InvalidInput(
507                        "--type conflicts with --field issuetype; choose one source".into(),
508                    ));
509                }
510                let target = self.resolve_type_change(key, input, meta.as_ref()).await?;
511                fields["issuetype"] = json!({"id": target.id});
512                Some(target)
513            }
514            None => None,
515        };
516        if let Some(epic) = update.epic {
517            if key.eq_ignore_ascii_case(epic) {
518                return Err(ApiError::InvalidInput(
519                    "An issue cannot be its own epic".into(),
520                ));
521            }
522            let target = self.issue_type_for_link(epic).await?;
523            if !target.is_epic() {
524                return Err(ApiError::InvalidInput(format!(
525                    "--epic {epic} targets issue type {:?}, not an Epic",
526                    target.name
527                )));
528            }
529            let issue_type = match &target_type {
530                Some(target) => target.clone(),
531                None => self.issue_type_for_link(key).await?,
532            };
533            self.set_epic(fields, meta.as_ref(), &issue_type, epic)
534                .await?;
535        }
536        if update.clear_epic {
537            let issue_type = match &target_type {
538                Some(target) => target.clone(),
539                None => self.issue_type_for_link(key).await?,
540            };
541            if !issue_type.can_belong_to_epic() {
542                return Err(ApiError::InvalidInput(format!(
543                    "Cannot use --clear-epic on issue type {:?}; only standard issues can have epic membership cleared",
544                    issue_type.name
545                )));
546            }
547            let field = self.epic_field(meta.as_ref()).await?;
548            validate_epic_override(fields, meta.as_ref(), &field)?;
549            fields[&field] = Value::Null;
550        }
551        validate_required(fields, meta.as_ref(), false)?;
552        Ok(meta)
553    }
554}
555
556fn validate_epic_override(
557    fields: &Value,
558    meta: Option<&Fields>,
559    field: &str,
560) -> Result<(), ApiError> {
561    let conflict = meta
562        .and_then(|meta| {
563            meta.iter()
564                .find(|(id, definition)| is_epic_field(id, definition) && fields.get(*id).is_some())
565                .map(|(id, _)| id.as_str())
566        })
567        .or_else(|| fields.get("parent").map(|_| "parent"))
568        .or_else(|| fields.get(field).map(|_| field));
569    if let Some(conflict) = conflict {
570        return Err(ApiError::InvalidInput(format!(
571            "Epic linkage conflicts with --field {conflict}; specify the relationship only once"
572        )));
573    }
574    Ok(())
575}
576
577fn validate_parent_type(
578    child: &IssueType,
579    parent: &IssueType,
580    key: &str,
581    subtask_types: &[Value],
582) -> Result<(), ApiError> {
583    if parent.is_subtask() {
584        return Err(ApiError::InvalidInput(format!(
585            "--parent {key} is a subtask and cannot have child issues; choose a Story or Task"
586        )));
587    }
588    if let (Some(child_level), Some(parent_level)) = (child.hierarchy_level, parent.hierarchy_level)
589    {
590        if child_level + 1 == parent_level {
591            return Ok(());
592        }
593        if parent_level != 0 {
594            return Err(ApiError::InvalidInput(format!(
595                "Issue type {:?} is not directly below parent type {:?} in the hierarchy",
596                child.name, parent.name
597            )));
598        }
599    } else if child.is_subtask() {
600        return Ok(());
601    }
602    Err(ApiError::InvalidInput(format!(
603        "--parent {key} requires a subtask issue type; selected {:?}. Choose --type from: {}",
604        child.name,
605        option_names(subtask_types)
606    )))
607}
608
609fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, ApiError> {
610    serde_json::from_value(value)
611        .map_err(|err| ApiError::Other(format!("Invalid Jira metadata: {err}")))
612}
613
614pub(super) fn encode_segment(value: &str) -> String {
615    let mut url = reqwest::Url::parse("http://localhost").expect("static URL");
616    url.path_segments_mut()
617        .expect("URL supports paths")
618        .push(value);
619    url.path().trim_start_matches('/').into()
620}
621
622fn named_id(id: &str, name: &str) -> Value {
623    if id.is_empty() {
624        json!({"name": name})
625    } else {
626        json!({"id": id})
627    }
628}
629
630pub(super) fn unresolved_option(input: &str) -> Value {
631    let input = input.trim();
632    if !input.is_empty() && input.bytes().all(|b| b.is_ascii_digit()) {
633        json!({"id": input})
634    } else {
635        json!({"name": input})
636    }
637}
638
639fn epic_link_candidates(fields: &Fields) -> Vec<String> {
640    let schema_matches = fields
641        .iter()
642        .filter(|(_, f)| f["schema"]["custom"] == EPIC_LINK_SCHEMA)
643        .map(|(id, _)| id.clone())
644        .collect::<Vec<_>>();
645    if schema_matches.is_empty() {
646        fields
647            .iter()
648            .filter(|(id, f)| is_epic_field(id, f))
649            .map(|(id, _)| id.clone())
650            .collect()
651    } else {
652        schema_matches
653    }
654}
655
656fn epic_candidates(fields: &Fields, api_version: u8) -> Vec<String> {
657    if api_version >= 3 && fields.contains_key("parent") {
658        return vec!["parent".into()];
659    }
660    let candidates = epic_link_candidates(fields);
661    if candidates.is_empty() && fields.contains_key("parent") {
662        vec!["parent".into()]
663    } else {
664        candidates
665    }
666}
667
668fn choose_epic_field(candidates: Vec<String>) -> Result<Option<String>, ApiError> {
669    match candidates.as_slice() {
670        [] => Ok(None),
671        [id] => Ok(Some(id.clone())),
672        _ => Err(ApiError::InvalidInput(format!(
673            "Ambiguous Epic Link fields: {}; use --field <ID>=<EPIC> to choose one",
674            candidates.join(", ")
675        ))),
676    }
677}
678
679fn find_epic_field(fields: &Fields) -> Result<Option<String>, ApiError> {
680    choose_epic_field(epic_link_candidates(fields))
681}
682
683fn is_epic_field(id: &str, field: &Value) -> bool {
684    field["schema"]["custom"] == EPIC_LINK_SCHEMA
685        || (id.starts_with("customfield_")
686            && field["name"]
687                .as_str()
688                .is_some_and(|name| name.eq_ignore_ascii_case("Epic Link")))
689}
690
691fn normalize_priority(
692    fields: &mut Value,
693    meta: Option<&Fields>,
694    input: &str,
695) -> Result<(), ApiError> {
696    let Some(meta) = meta else {
697        return Ok(());
698    };
699    let priority = meta.get("priority").ok_or_else(|| ApiError::InvalidInput(
700        "Priority is not available on this issue's create/edit screen; omit --priority to keep the project default or ask an administrator to enable it".into()
701    ))?;
702    if let Some(options) = priority["allowedValues"].as_array() {
703        let selected = &options[match_option("priority", input, options, OptionMatch::Priority)?];
704        fields["priority"] = named_id(
705            selected["id"].as_str().unwrap_or(""),
706            selected["name"].as_str().unwrap_or(input),
707        );
708    }
709    Ok(())
710}
711
712/// Prefer an exact name/ID, then case-insensitive names, then a unique label
713/// with a numeric rank removed, then prefixes. Never guess between ties.
714#[derive(Clone, Copy, PartialEq)]
715enum OptionMatch {
716    NameOrId,
717    Prefix,
718    Priority,
719}
720
721fn match_option(
722    field: &str,
723    input: &str,
724    options: &[Value],
725    style: OptionMatch,
726) -> Result<usize, ApiError> {
727    let input = input.trim();
728    let lower = input.to_lowercase();
729    for tier in 0..4 {
730        let candidates = options
731            .iter()
732            .enumerate()
733            .filter(|(_, option)| {
734                let name = option["name"].as_str().unwrap_or("");
735                !input.is_empty()
736                    && match tier {
737                        0 => name == input || option["id"].as_str() == Some(input),
738                        1 => name.to_lowercase() == lower,
739                        2 => {
740                            style == OptionMatch::Priority
741                                && priority_label(name).to_lowercase() == lower
742                        }
743                        _ => {
744                            style != OptionMatch::NameOrId
745                                && (name.to_lowercase().starts_with(&lower)
746                                    || (style == OptionMatch::Priority
747                                        && priority_label(name).to_lowercase().starts_with(&lower)))
748                        }
749                    }
750            })
751            .map(|(index, _)| index)
752            .collect::<Vec<_>>();
753        if let [index] = candidates.as_slice() {
754            return Ok(*index);
755        }
756        if candidates.len() > 1 {
757            return Err(option_error(field, input, options, true));
758        }
759    }
760    Err(option_error(field, input, options, false))
761}
762
763fn priority_label(name: &str) -> &str {
764    let name = name.trim();
765    let rest = name.trim_start_matches(|c: char| c.is_ascii_digit());
766    if rest.len() != name.len() {
767        let rest = rest.trim_start();
768        if let Some(label) = rest.strip_prefix(['-', '\u{2013}', '\u{2014}', ':', '.']) {
769            return label.trim();
770        }
771    }
772    name
773}
774
775fn option_error(field: &str, input: &str, options: &[Value], ambiguous: bool) -> ApiError {
776    ApiError::InvalidInput(format!(
777        "{} {field} {input:?}; valid: {}",
778        if ambiguous { "Ambiguous" } else { "Invalid" },
779        option_names(options)
780    ))
781}
782
783fn option_names(options: &[Value]) -> String {
784    if options.is_empty() {
785        return "(none available)".into();
786    }
787    options
788        .iter()
789        .map(|option| {
790            let name = option["name"].as_str().unwrap_or("(unnamed)");
791            match option["id"].as_str() {
792                Some(id) => format!("{name:?} (ID {id})"),
793                None => format!("{name:?}"),
794            }
795        })
796        .collect::<Vec<_>>()
797        .join(", ")
798}
799
800pub(super) fn create_error(
801    err: ApiError,
802    meta: Option<&CreateMetadata>,
803    draft: &super::IssueDraft<'_>,
804) -> ApiError {
805    write_error(
806        err,
807        meta.and_then(|m| m.fields.as_ref()),
808        draft.priority,
809        draft.epic.or(draft.parent),
810    )
811}
812
813pub(super) fn write_error(
814    err: ApiError,
815    meta: Option<&Fields>,
816    priority: Option<&str>,
817    link: Option<&str>,
818) -> ApiError {
819    if let ApiError::Api {
820        status: 400,
821        mut message,
822    } = err
823    {
824        if message.contains("priority")
825            && let Some(input) = priority
826        {
827            message.push_str(&format!("; priority {input:?}"));
828            if let Some(options) = meta
829                .and_then(|m| m.get("priority"))
830                .and_then(|p| p["allowedValues"].as_array())
831            {
832                message.push_str(&format!("; valid: {}", option_names(options)));
833            } else {
834                message.push_str("; use the exact project priority name or ID, or omit --priority to keep the default");
835            }
836        }
837        for field in ["components", "fixVersions"] {
838            if message.contains(field) {
839                if let Some(options) = meta
840                    .and_then(|m| m.get(field))
841                    .and_then(|f| f["allowedValues"].as_array())
842                {
843                    message.push_str(&format!("; valid {field}: {}", option_names(options)));
844                } else {
845                    message.push_str(&format!(
846                        "; use an exact project {field} name or ID, or omit the field"
847                    ));
848                }
849            }
850        }
851        if let Some(key) = link
852            && (message.contains("issuetype")
853                || message.contains("parent")
854                || meta.is_some_and(|m| {
855                    m.iter()
856                        .any(|(id, f)| is_epic_field(id, f) && message.contains(id))
857                }))
858        {
859            message.push_str(&format!("; to add a Story or Task to an Epic use --epic {key}; subtasks require --parent <STORY-OR-TASK>. Check that Epic Link/parent is enabled on the create/edit screen"));
860        }
861        ApiError::Api {
862            status: 400,
863            message,
864        }
865    } else {
866        err
867    }
868}
869
870fn type_json(t: &IssueType) -> Value {
871    json!({"id": if t.id.is_empty() { None } else { Some(&t.id) }, "name": t.name,
872        "subtask": t.is_subtask(), "hierarchyLevel": t.hierarchy_level})
873}