use anyhow::Result;
use clap::Parser;
use crate::atlassian::client::{AtlassianClient, JiraTransition};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct TransitionCommand {
pub key: String,
pub transition: Option<String>,
#[arg(long)]
pub list: bool,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl TransitionCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_transition(
&client,
&self.key,
self.transition.as_deref(),
self.list,
&self.output,
)
.await
}
}
async fn run_transition(
client: &AtlassianClient,
key: &str,
transition: Option<&str>,
list: bool,
output: &OutputFormat,
) -> Result<()> {
let transitions = client.get_transitions(key).await?;
let Some(target) = transition.filter(|_| !list) else {
if output_as(&transitions, output)? {
return Ok(());
}
print_transitions(&transitions);
return Ok(());
};
let matched = resolve_transition(target, &transitions)?;
client.do_transition(key, &matched.id).await?;
println!("Transitioned {key} to \"{}\".", matched.name);
Ok(())
}
fn resolve_transition<'a>(
target: &str,
transitions: &'a [JiraTransition],
) -> Result<&'a JiraTransition> {
if let Some(t) = transitions.iter().find(|t| t.id == target) {
return Ok(t);
}
let target_lower = target.to_lowercase();
let matches: Vec<_> = transitions
.iter()
.filter(|t| t.name.to_lowercase() == target_lower)
.collect();
match matches.len() {
0 => {
let available: Vec<_> = transitions
.iter()
.map(|t| format!("\"{}\" (id: {})", t.name, t.id))
.collect();
anyhow::bail!(
"No transition matching \"{target}\" found.\nAvailable transitions: {}",
if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
}
)
}
1 => Ok(matches[0]),
_ => {
let dupes: Vec<_> = matches
.iter()
.map(|t| format!("\"{}\" (id: {})", t.name, t.id))
.collect();
anyhow::bail!(
"Ambiguous transition \"{target}\". Matches: {}. Use the transition ID instead.",
dupes.join(", ")
)
}
}
}
fn print_transitions(transitions: &[JiraTransition]) {
if transitions.is_empty() {
println!("No transitions available.");
return;
}
let id_width = transitions
.iter()
.map(|t| t.id.len())
.max()
.unwrap_or(2)
.max(2);
println!("{:<id_width$} NAME", "ID");
let name_sep = "-".repeat(4);
println!("{:<id_width$} {name_sep}", "-".repeat(id_width));
for t in transitions {
println!("{:<id_width$} {}", t.id, t.name);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_transitions() -> Vec<JiraTransition> {
vec![
JiraTransition {
id: "11".to_string(),
name: "In Progress".to_string(),
},
JiraTransition {
id: "21".to_string(),
name: "Done".to_string(),
},
JiraTransition {
id: "31".to_string(),
name: "Won't Do".to_string(),
},
]
}
#[test]
fn resolve_by_exact_id() {
let transitions = sample_transitions();
let result = resolve_transition("21", &transitions).unwrap();
assert_eq!(result.name, "Done");
}
#[test]
fn resolve_by_name_case_insensitive() {
let transitions = sample_transitions();
let result = resolve_transition("in progress", &transitions).unwrap();
assert_eq!(result.id, "11");
}
#[test]
fn resolve_by_name_exact_case() {
let transitions = sample_transitions();
let result = resolve_transition("Done", &transitions).unwrap();
assert_eq!(result.id, "21");
}
#[test]
fn resolve_not_found() {
let transitions = sample_transitions();
let err = resolve_transition("Cancelled", &transitions).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("No transition matching"));
assert!(msg.contains("In Progress"));
assert!(msg.contains("Done"));
}
#[test]
fn resolve_not_found_empty_list() {
let err = resolve_transition("Done", &[]).unwrap_err();
assert!(err.to_string().contains("none"));
}
#[test]
fn resolve_ambiguous() {
let transitions = vec![
JiraTransition {
id: "11".to_string(),
name: "Done".to_string(),
},
JiraTransition {
id: "21".to_string(),
name: "Done".to_string(),
},
];
let err = resolve_transition("Done", &transitions).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Ambiguous"));
assert!(msg.contains("id: 11"));
assert!(msg.contains("id: 21"));
}
#[test]
fn resolve_id_takes_priority_over_name() {
let transitions = vec![
JiraTransition {
id: "Done".to_string(),
name: "Something Else".to_string(),
},
JiraTransition {
id: "99".to_string(),
name: "Done".to_string(),
},
];
let result = resolve_transition("Done", &transitions).unwrap();
assert_eq!(result.name, "Something Else"); }
#[test]
fn print_transitions_with_items() {
let transitions = sample_transitions();
print_transitions(&transitions);
}
#[test]
fn print_transitions_empty() {
print_transitions(&[]);
}
#[tokio::test]
async fn run_transition_list_mode() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [
{"id": "11", "name": "In Progress"},
{"id": "21", "name": "Done"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
assert!(
run_transition(&client, "PROJ-1", None, true, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transition_execute_by_name() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [
{"id": "11", "name": "In Progress"},
{"id": "21", "name": "Done"}
]
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
assert!(
run_transition(&client, "PROJ-1", Some("Done"), false, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transition_resolve_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/transitions",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [{"id": "11", "name": "In Progress"}]
})),
)
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let err = run_transition(
&client,
"PROJ-1",
Some("Nonexistent"),
false,
&OutputFormat::Table,
)
.await
.unwrap_err();
assert!(err.to_string().contains("No transition matching"));
}
#[tokio::test]
async fn run_transition_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/NOPE-1/transitions",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let err = run_transition(&client, "NOPE-1", None, true, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn transition_command_list_mode() {
let cmd = TransitionCommand {
key: "PROJ-1".to_string(),
transition: None,
list: true,
output: OutputFormat::Table,
};
assert!(cmd.list);
assert!(cmd.transition.is_none());
}
#[test]
fn transition_command_execute_mode() {
let cmd = TransitionCommand {
key: "PROJ-1".to_string(),
transition: Some("Done".to_string()),
list: false,
output: OutputFormat::Table,
};
assert_eq!(cmd.transition.as_deref(), Some("Done"));
}
}