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