use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::{AtlassianClient, JiraField, JiraFieldOption};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct FieldCommand {
#[command(subcommand)]
pub command: FieldSubcommands,
}
#[derive(Subcommand)]
pub enum FieldSubcommands {
List(ListCommand),
Options(OptionsCommand),
}
impl FieldCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
FieldSubcommands::List(cmd) => cmd.execute().await,
FieldSubcommands::Options(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long)]
pub search: Option<String>,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_list_fields(&client, self.search.as_deref(), &self.output).await
}
}
#[derive(Parser)]
pub struct OptionsCommand {
#[arg(long)]
pub field_id: String,
#[arg(long)]
pub context_id: Option<String>,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl OptionsCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_field_options(
&client,
&self.field_id,
self.context_id.as_deref(),
&self.output,
)
.await
}
}
async fn run_list_fields(
client: &AtlassianClient,
search: Option<&str>,
output: &OutputFormat,
) -> Result<()> {
let fields = client.get_fields().await?;
let filtered = filter_fields(&fields, search);
if output_as(&filtered, output)? {
return Ok(());
}
print_fields(&filtered);
Ok(())
}
async fn run_field_options(
client: &AtlassianClient,
field_id: &str,
context_id: Option<&str>,
output: &OutputFormat,
) -> Result<()> {
let options = client.get_field_options(field_id, context_id).await?;
if output_as(&options, output)? {
return Ok(());
}
print_options(&options);
Ok(())
}
fn filter_fields<'a>(fields: &'a [JiraField], search: Option<&str>) -> Vec<&'a JiraField> {
match search {
Some(query) => {
let query_lower = query.to_lowercase();
fields
.iter()
.filter(|f| f.name.to_lowercase().contains(&query_lower))
.collect()
}
None => fields.iter().collect(),
}
}
fn print_fields(fields: &[&JiraField]) {
if fields.is_empty() {
println!("No fields found.");
return;
}
let id_width = fields.iter().map(|f| f.id.len()).max().unwrap_or(2).max(2);
let type_width = fields
.iter()
.filter_map(|f| f.schema_type.as_ref().map(String::len))
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<id_width$} {:<6} {:<type_width$} NAME",
"ID", "CUSTOM", "TYPE"
);
let name_sep = "-".repeat(4);
println!(
"{:<id_width$} {:<6} {:<type_width$} {name_sep}",
"-".repeat(id_width),
"-".repeat(6),
"-".repeat(type_width),
);
for field in fields {
let custom = if field.custom { "yes" } else { "no" };
let schema = field.schema_type.as_deref().unwrap_or("-");
println!(
"{:<id_width$} {:<6} {:<type_width$} {}",
field.id, custom, schema, field.name
);
}
}
fn print_options(options: &[JiraFieldOption]) {
if options.is_empty() {
println!("No options found.");
return;
}
let id_width = options.iter().map(|o| o.id.len()).max().unwrap_or(2).max(2);
println!("{:<id_width$} VALUE", "ID");
let val_sep = "-".repeat(5);
println!("{:<id_width$} {val_sep}", "-".repeat(id_width));
for option in options {
println!("{:<id_width$} {}", option.id, option.value);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_field(id: &str, name: &str, custom: bool, schema_type: Option<&str>) -> JiraField {
JiraField {
id: id.to_string(),
name: name.to_string(),
custom,
schema_type: schema_type.map(String::from),
}
}
fn sample_option(id: &str, value: &str) -> JiraFieldOption {
JiraFieldOption {
id: id.to_string(),
value: value.to_string(),
}
}
#[test]
fn filter_no_query_returns_all() {
let fields = vec![
sample_field("summary", "Summary", false, Some("string")),
sample_field("customfield_10001", "Story Points", true, Some("number")),
];
let result = filter_fields(&fields, None);
assert_eq!(result.len(), 2);
}
#[test]
fn filter_by_name_case_insensitive() {
let fields = vec![
sample_field("summary", "Summary", false, Some("string")),
sample_field("customfield_10001", "Story Points", true, Some("number")),
sample_field("status", "Status", false, Some("status")),
];
let result = filter_fields(&fields, Some("story"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "customfield_10001");
}
#[test]
fn filter_no_match() {
let fields = vec![sample_field("summary", "Summary", false, None)];
let result = filter_fields(&fields, Some("nonexistent"));
assert!(result.is_empty());
}
#[test]
fn print_fields_empty() {
print_fields(&[]);
}
#[test]
fn print_fields_with_data() {
let fields = vec![
sample_field("summary", "Summary", false, Some("string")),
sample_field("customfield_10001", "Story Points", true, Some("number")),
];
let refs: Vec<&JiraField> = fields.iter().collect();
print_fields(&refs);
}
#[test]
fn print_fields_no_schema() {
let fields = vec![sample_field("labels", "Labels", false, None)];
let refs: Vec<&JiraField> = fields.iter().collect();
print_fields(&refs);
}
#[test]
fn print_options_empty() {
print_options(&[]);
}
#[test]
fn print_options_with_data() {
let options = vec![
sample_option("1", "High"),
sample_option("2", "Medium"),
sample_option("3", "Low"),
];
print_options(&options);
}
#[test]
fn field_command_list_variant() {
let cmd = FieldCommand {
command: FieldSubcommands::List(ListCommand {
search: None,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, FieldSubcommands::List(_)));
}
#[test]
fn field_command_options_variant() {
let cmd = FieldCommand {
command: FieldSubcommands::Options(OptionsCommand {
field_id: "customfield_10001".to_string(),
context_id: None,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, FieldSubcommands::Options(_)));
}
#[test]
fn list_command_with_search() {
let cmd = ListCommand {
search: Some("story".to_string()),
output: OutputFormat::Table,
};
assert_eq!(cmd.search.as_deref(), Some("story"));
}
#[test]
fn options_command_with_context() {
let cmd = OptionsCommand {
field_id: "customfield_10001".to_string(),
context_id: Some("12345".to_string()),
output: OutputFormat::Table,
};
assert_eq!(cmd.context_id.as_deref(), Some("12345"));
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_fields_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/field"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"id": "summary", "name": "Summary", "custom": false}
])),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_fields(&client, None, &OutputFormat::Table)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_fields_with_filter() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/field"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"id": "summary", "name": "Summary", "custom": false},
{"id": "customfield_1", "name": "Story Points", "custom": true}
])),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_fields(&client, Some("story"), &OutputFormat::Json)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_fields_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/field"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_fields(&client, None, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[tokio::test]
async fn run_field_options_with_context_id() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context/12345/option",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": "1", "value": "Option A"}]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_field_options(
&client,
"customfield_10001",
Some("12345"),
&OutputFormat::Table
)
.await
.is_ok());
}
#[tokio::test]
async fn run_field_options_auto_discovery() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": "12345", "name": "Default"}]
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/field/customfield_10001/context/12345/option",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": []})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_field_options(&client, "customfield_10001", None, &OutputFormat::Json)
.await
.is_ok()
);
}
}