use anyhow::{anyhow, bail, Context, Result};
use atlassian_cli_api::pagination::{fetch_paged, JiraPage, PageLimits};
use atlassian_cli_output::OutputFormat;
use serde::Deserialize;
use serde_json::{Map, Value};
use super::utils::JiraContext;
const ALL_FIELDS: &str = "*all";
const KEY_COLUMN: &str = "key";
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct FieldDef {
pub id: String,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedField {
pub token: String,
pub id: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum FieldSelection {
Raw(String),
Explicit(Vec<ResolvedField>),
}
impl FieldSelection {
pub fn query_value(&self) -> String {
match self {
FieldSelection::Raw(raw) => raw.clone(),
FieldSelection::Explicit(fields) => fields
.iter()
.map(|f| f.id.as_str())
.collect::<Vec<_>>()
.join(","),
}
}
}
fn is_wildcard(token: &str) -> bool {
let trimmed = token.trim();
trimmed.eq_ignore_ascii_case("all") || trimmed.starts_with('*') || trimmed.starts_with('-')
}
fn wildcard_value(token: &str) -> String {
let trimmed = token.trim();
if trimmed.eq_ignore_ascii_case("all") {
ALL_FIELDS.to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn needs_field_lookup(tokens: &[String]) -> bool {
!tokens.iter().all(|token| {
let token = token.trim();
token
.strip_prefix("customfield_")
.is_some_and(|digits| !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()))
})
}
pub(crate) fn match_fields(tokens: &[String], defs: &[FieldDef]) -> Result<Vec<ResolvedField>> {
let mut resolved: Vec<ResolvedField> = Vec::with_capacity(tokens.len());
for token in tokens {
let trimmed = token.trim();
if trimmed.is_empty() {
continue;
}
if resolved.iter().any(|r| r.token == trimmed) {
continue;
}
let candidate = resolve_one(trimmed, defs)?;
if resolved.iter().any(|r| r.id == candidate.id) {
tracing::debug!(
token = %candidate.token,
id = %candidate.id,
"field already requested under another name, skipping"
);
continue;
}
resolved.push(candidate);
}
if resolved.is_empty() {
bail!("--fields was given no field names");
}
Ok(resolved)
}
fn resolve_one(token: &str, defs: &[FieldDef]) -> Result<ResolvedField> {
if let Some(def) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(token)) {
return Ok(ResolvedField {
token: token.to_string(),
id: def.id.clone(),
});
}
let by_name: Vec<&FieldDef> = defs
.iter()
.filter(|d| d.name.trim().eq_ignore_ascii_case(token))
.collect();
match by_name.as_slice() {
[single] => Ok(ResolvedField {
token: token.to_string(),
id: single.id.clone(),
}),
[] => Err(anyhow!(
"Unknown field '{token}'. Run `atlassian-cli jira fields list` to see \
the fields available on this site."
)),
many => {
let candidates = many
.iter()
.map(|d| format!(" {} {}", d.id, d.name))
.collect::<Vec<_>>()
.join("\n");
Err(anyhow!(
"Field name '{token}' is ambiguous on this site. Candidates:\n{candidates}\n\
Pass the id instead, for example --fields {}.",
many[0].id
))
}
}
}
pub(crate) async fn resolve_field_ids(
ctx: &JiraContext<'_>,
tokens: &[String],
) -> Result<FieldSelection> {
if tokens.iter().all(|t| t.trim().is_empty()) {
bail!("--fields was given no field names");
}
if let Some(wildcard) = tokens.iter().find(|t| is_wildcard(t)) {
if tokens.len() > 1 {
tracing::warn!(
"a wildcard in --fields returns every field, so the other names \
do not narrow the result"
);
}
let value = if tokens.len() == 1 {
wildcard_value(wildcard)
} else {
tokens
.iter()
.map(|t| wildcard_value(t))
.collect::<Vec<_>>()
.join(",")
};
tracing::debug!(fields = %value, "requesting fields by wildcard");
return Ok(FieldSelection::Raw(value));
}
if !needs_field_lookup(tokens) {
tracing::debug!("every requested field is a custom field id, skipping the field lookup");
let resolved = tokens
.iter()
.map(|t| ResolvedField {
token: t.trim().to_string(),
id: t.trim().to_string(),
})
.collect();
return Ok(FieldSelection::Explicit(resolved));
}
let defs: Vec<FieldDef> = ctx
.client
.get("/rest/api/3/field")
.await
.context("Failed to list this site's fields to resolve --fields")?;
tracing::debug!(count = defs.len(), "fetched field definitions");
let resolved = match_fields(tokens, &defs)?;
tracing::debug!(
fields = %resolved.iter().map(|r| r.id.as_str()).collect::<Vec<_>>().join(","),
"resolved --fields"
);
Ok(FieldSelection::Explicit(resolved))
}
#[derive(Debug, Deserialize)]
struct RawIssue {
key: String,
#[serde(default)]
fields: Map<String, Value>,
}
fn project_issue(issue: &RawIssue, selection: &FieldSelection) -> (Value, Vec<String>) {
let mut row = Map::new();
let mut columns = vec![KEY_COLUMN.to_string()];
row.insert(KEY_COLUMN.to_string(), Value::String(issue.key.clone()));
match selection {
FieldSelection::Raw(_) => {
for (name, value) in &issue.fields {
if name == KEY_COLUMN {
continue;
}
columns.push(name.clone());
row.insert(name.clone(), value.clone());
}
}
FieldSelection::Explicit(fields) => {
for field in fields {
if field.token == KEY_COLUMN || field.id == KEY_COLUMN {
continue;
}
let value = issue.fields.get(&field.id).cloned().unwrap_or(Value::Null);
if value.is_null() && !issue.fields.contains_key(&field.id) {
tracing::warn!(
field = %field.id,
issue = %issue.key,
"requested field is absent from the response"
);
}
columns.push(field.token.clone());
row.insert(field.token.clone(), value);
}
}
}
(Value::Object(row), columns)
}
pub(crate) fn friendly_scalar(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(items) => items
.iter()
.map(|item| match item {
Value::Array(_) => compact(item),
other => friendly_scalar(other),
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", "),
Value::Object(obj) => {
if obj.get("type").and_then(Value::as_str) == Some("doc") {
return super::issues::extract_adf_text(value);
}
for label in ["displayName", "name", "value", "filename", "key"] {
if let Some(text) = obj.get(label).and_then(Value::as_str) {
if !text.is_empty() {
return text.to_string();
}
}
}
compact(value)
}
}
}
fn compact(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_default()
}
fn humanize(row: &Value, format: OutputFormat) -> Value {
if !matches!(
format,
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Markdown
) {
return row.clone();
}
match row {
Value::Object(obj) => Value::Object(
obj.iter()
.map(|(k, v)| (k.clone(), Value::String(friendly_scalar(v))))
.collect(),
),
other => other.clone(),
}
}
pub(crate) async fn view_issue_fields(
ctx: &JiraContext<'_>,
key: &str,
tokens: &[String],
) -> Result<()> {
let selection = resolve_field_ids(ctx, tokens).await?;
let issue: RawIssue = ctx
.client
.get(&format!(
"/rest/api/3/issue/{key}?fields={}",
urlencoding::encode(&selection.query_value())
))
.await
.with_context(|| format!("Failed to fetch issue {key}"))?;
let (row, columns) = project_issue(&issue, &selection);
match ctx.renderer.format() {
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Markdown => {
if ctx.renderer.format() == OutputFormat::Markdown {
ctx.renderer.render_raw(&format!("# {}\n", issue.key))?;
}
let obj = row.as_object().expect("project_issue returns an object");
let pairs: Vec<Value> = columns
.iter()
.filter_map(|column| {
obj.get(column).map(|value| {
serde_json::json!({
"field": column,
"value": friendly_scalar(value),
})
})
})
.collect();
ctx.renderer
.render_rows_ordered(&pairs, &["field".to_string(), "value".to_string()])
}
_ => ctx.renderer.render(&row),
}
}
pub(crate) async fn search_rows(
ctx: &JiraContext<'_>,
jql: &str,
limit: usize,
tokens: &[String],
) -> Result<()> {
let selection = resolve_field_ids(ctx, tokens).await?;
let page_size = if limit == 0 { 100 } else { limit.clamp(1, 100) };
let query = format!(
"/rest/api/3/search/jql?jql={}&maxResults={page_size}&fields={}",
urlencoding::encode(jql),
urlencoding::encode(&selection.query_value())
);
let (issues, page) =
fetch_paged::<JiraPage<RawIssue>>(&ctx.client, &query, PageLimits::from_cli_limit(limit))
.await
.context("Failed to execute search")?;
if page.truncated {
eprintln!(
"warning: showing {} issues; more match this query. Raise --limit, or use --limit 0 for all.",
issues.len()
);
}
if issues.is_empty() {
ctx.verify_auth().await?;
tracing::info!("No issues found");
return ctx
.renderer
.render_list_or_empty(&Vec::<Value>::new(), "No issues found");
}
let mut columns: Vec<String> = vec![KEY_COLUMN.to_string()];
let mut rows = Vec::with_capacity(issues.len());
for issue in &issues {
let (row, row_columns) = project_issue(issue, &selection);
for column in row_columns {
if !columns.contains(&column) {
columns.push(column);
}
}
rows.push(humanize(&row, ctx.renderer.format()));
}
match ctx.renderer.format() {
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Markdown => {
ctx.renderer.render_rows_ordered(&rows, &columns)
}
_ => ctx.renderer.render(&rows),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn defs() -> Vec<FieldDef> {
vec![
FieldDef {
id: "summary".into(),
name: "Summary".into(),
},
FieldDef {
id: "status".into(),
name: "Status".into(),
},
FieldDef {
id: "customfield_10016".into(),
name: "Story Points".into(),
},
FieldDef {
id: "customfield_10020".into(),
name: "Sprint".into(),
},
]
}
fn tokens(values: &[&str]) -> Vec<String> {
values.iter().map(|v| v.to_string()).collect()
}
#[test]
fn custom_field_ids_alone_need_no_lookup() {
assert!(!needs_field_lookup(&tokens(&[
"customfield_10016",
"customfield_10020"
])));
}
#[test]
fn any_possible_name_triggers_a_lookup() {
assert!(needs_field_lookup(&tokens(&[
"customfield_10016",
"status"
])));
assert!(needs_field_lookup(&tokens(&["summary"])));
assert!(needs_field_lookup(&tokens(&["customfield_abc"])));
assert!(needs_field_lookup(&tokens(&["customfield_"])));
}
#[test]
fn all_is_a_wildcard_whatever_its_case() {
for token in ["all", "ALL", "All", " all "] {
assert!(is_wildcard(token), "{token} should be a wildcard");
assert_eq!(wildcard_value(token), ALL_FIELDS);
}
}
#[test]
fn jira_wildcards_pass_through_untouched() {
for token in ["*all", "*navigable", "-comment"] {
assert!(is_wildcard(token));
assert_eq!(wildcard_value(token), token);
}
}
#[test]
fn a_field_id_resolves_to_itself() {
let resolved = match_fields(&tokens(&["summary"]), &defs()).unwrap();
assert_eq!(resolved[0].id, "summary");
assert_eq!(resolved[0].token, "summary");
}
#[test]
fn a_display_name_resolves_case_insensitively() {
let resolved = match_fields(&tokens(&["story points"]), &defs()).unwrap();
assert_eq!(resolved[0].id, "customfield_10016");
assert_eq!(resolved[0].token, "story points");
}
#[test]
fn an_id_wins_over_a_custom_field_with_the_same_name() {
let mut defs = defs();
defs.push(FieldDef {
id: "customfield_10099".into(),
name: "Summary".into(),
});
let resolved = match_fields(&tokens(&["summary"]), &defs).unwrap();
assert_eq!(resolved[0].id, "summary");
}
#[test]
fn an_unknown_name_points_at_the_fields_command() {
let error = match_fields(&tokens(&["stroy points"]), &defs()).unwrap_err();
let message = error.to_string();
assert!(message.contains("stroy points"), "{message}");
assert!(message.contains("jira fields list"), "{message}");
}
#[test]
fn an_ambiguous_name_errors_and_lists_every_candidate() {
let mut defs = defs();
defs.push(FieldDef {
id: "customfield_10032".into(),
name: "Story Points".into(),
});
let error = match_fields(&tokens(&["Story Points"]), &defs).unwrap_err();
let message = error.to_string();
assert!(message.contains("ambiguous"), "{message}");
assert!(message.contains("customfield_10016"), "{message}");
assert!(message.contains("customfield_10032"), "{message}");
}
#[test]
fn duplicate_tokens_collapse_and_keep_their_first_position() {
let resolved = match_fields(&tokens(&["status", "summary", "status"]), &defs()).unwrap();
let ids: Vec<&str> = resolved.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["status", "summary"]);
}
#[test]
fn two_names_for_the_same_field_collapse_to_one_column() {
let resolved = match_fields(&tokens(&["summary", "Summary"]), &defs()).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].token, "summary", "the first spelling wins");
let resolved =
match_fields(&tokens(&["customfield_10016", "Story Points"]), &defs()).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, "customfield_10016");
}
#[test]
fn resolution_preserves_the_order_typed() {
let resolved =
match_fields(&tokens(&["Story Points", "status", "summary"]), &defs()).unwrap();
let ids: Vec<&str> = resolved.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["customfield_10016", "status", "summary"]);
}
#[test]
fn the_query_value_joins_ids_in_order() {
let selection = FieldSelection::Explicit(
match_fields(&tokens(&["status", "Story Points"]), &defs()).unwrap(),
);
assert_eq!(selection.query_value(), "status,customfield_10016");
assert_eq!(FieldSelection::Raw("*all".into()).query_value(), "*all");
}
fn raw_issue(fields: Value) -> RawIssue {
RawIssue {
key: "DEV-1".to_string(),
fields: fields.as_object().unwrap().clone(),
}
}
#[test]
fn projection_puts_key_first_and_keeps_the_requested_order() {
let issue = raw_issue(json!({"status": {"name": "Done"}, "summary": "Fix it"}));
let selection = FieldSelection::Explicit(
match_fields(&tokens(&["summary", "status"]), &defs()).unwrap(),
);
let (row, columns) = project_issue(&issue, &selection);
assert_eq!(columns, vec!["key", "summary", "status"]);
assert_eq!(row["key"], json!("DEV-1"));
assert_eq!(row["summary"], json!("Fix it"));
}
#[test]
fn projection_yields_null_for_a_field_the_response_omitted() {
let issue = raw_issue(json!({"summary": "Fix it"}));
let selection = FieldSelection::Explicit(
match_fields(&tokens(&["summary", "Story Points"]), &defs()).unwrap(),
);
let (row, columns) = project_issue(&issue, &selection);
assert_eq!(columns, vec!["key", "summary", "Story Points"]);
assert_eq!(row["Story Points"], Value::Null);
}
#[test]
fn projection_does_not_duplicate_the_key_column() {
let issue = raw_issue(json!({"summary": "Fix it"}));
let selection = FieldSelection::Explicit(vec![
ResolvedField {
token: "key".into(),
id: "key".into(),
},
ResolvedField {
token: "summary".into(),
id: "summary".into(),
},
]);
let (_, columns) = project_issue(&issue, &selection);
assert_eq!(columns, vec!["key", "summary"]);
}
#[test]
fn friendly_scalar_passes_scalars_through() {
assert_eq!(friendly_scalar(&json!("text")), "text");
assert_eq!(friendly_scalar(&json!(5)), "5");
assert_eq!(friendly_scalar(&json!(true)), "true");
assert_eq!(friendly_scalar(&Value::Null), "");
}
#[test]
fn friendly_scalar_reads_the_usual_wrapper_objects() {
assert_eq!(friendly_scalar(&json!({"value": "Internal"})), "Internal");
assert_eq!(friendly_scalar(&json!({"name": "High"})), "High");
assert_eq!(friendly_scalar(&json!({"displayName": "Ada"})), "Ada");
assert_eq!(friendly_scalar(&json!({"filename": "log.txt"})), "log.txt");
assert_eq!(friendly_scalar(&json!({"key": "DEV-2"})), "DEV-2");
}
#[test]
fn friendly_scalar_prefers_display_name_over_a_sibling_name() {
let user = json!({"name": "ada.l", "displayName": "Ada Lovelace"});
assert_eq!(friendly_scalar(&user), "Ada Lovelace");
}
#[test]
fn friendly_scalar_extracts_rich_text() {
let adf = json!({
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "Hello there"}]
}]
});
assert_eq!(friendly_scalar(&adf).trim(), "Hello there");
}
#[test]
fn friendly_scalar_joins_arrays_of_options() {
let labels = json!([{"value": "one"}, {"value": "two"}]);
assert_eq!(friendly_scalar(&labels), "one, two");
assert_eq!(friendly_scalar(&json!(["a", "b"])), "a, b");
assert_eq!(friendly_scalar(&json!([])), "");
}
#[test]
fn friendly_scalar_falls_back_to_compact_json() {
let odd = json!({"unexpected": 1});
assert_eq!(friendly_scalar(&odd), "{\"unexpected\":1}");
}
#[test]
fn humanize_leaves_json_untouched_and_flattens_tables() {
let row = json!({"points": {"value": "Internal"}});
let as_json = humanize(&row, OutputFormat::Json);
assert_eq!(as_json["points"], json!({"value": "Internal"}));
let as_table = humanize(&row, OutputFormat::Table);
assert_eq!(as_table["points"], json!("Internal"));
}
}