use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::AtlassianClient;
use crate::atlassian::jira_types::JiraComponent;
use crate::cli::atlassian::confirm::{guard_destructive_with_io, GuardOptions, GuardOutcome};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct ComponentCommand {
#[command(subcommand)]
pub command: ComponentSubcommands,
}
#[derive(Subcommand)]
pub enum ComponentSubcommands {
List(ListCommand),
Create(CreateCommand),
Update(UpdateCommand),
Delete(DeleteCommand),
}
impl ComponentCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
ComponentSubcommands::List(cmd) => cmd.execute().await,
ComponentSubcommands::Create(cmd) => cmd.execute().await,
ComponentSubcommands::Update(cmd) => cmd.execute().await,
ComponentSubcommands::Delete(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long)]
pub project: 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()?;
let components = client.get_project_components(&self.project).await?;
if output_as(&components, &self.output)? {
return Ok(());
}
print_components(&components);
Ok(())
}
}
#[derive(Parser)]
pub struct CreateCommand {
#[arg(long)]
pub project: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub description: Option<String>,
}
impl CreateCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let component = client
.create_component(&self.project, &self.name, self.description.as_deref())
.await?;
println!(
"Created component {} (id: {}).",
component.name, component.id
);
Ok(())
}
}
#[derive(Parser)]
pub struct UpdateCommand {
pub component_id: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub description: Option<String>,
}
impl UpdateCommand {
pub async fn execute(self) -> Result<()> {
if self.name.is_none() && self.description.is_none() {
anyhow::bail!("Nothing to update: pass --name and/or --description.");
}
let (client, _instance_url) = create_client()?;
client
.update_component(
&self.component_id,
self.name.as_deref(),
self.description.as_deref(),
)
.await?;
println!("Updated component {}.", self.component_id);
Ok(())
}
}
#[derive(Parser)]
pub struct DeleteCommand {
pub component_id: String,
#[arg(long)]
pub move_issues_to: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub dry_run: bool,
}
impl DeleteCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let mut reader = std::io::BufReader::new(std::io::stdin());
let mut writer = std::io::stdout();
self.execute_with_io(&client, &mut reader, &mut writer)
.await
}
async fn execute_with_io(
self,
client: &AtlassianClient,
reader: &mut (dyn std::io::BufRead + Send),
writer: &mut (dyn std::io::Write + Send),
) -> Result<()> {
if !self.force || self.dry_run {
let prompt = format!("Delete component {}? [y/N] ", self.component_id);
let dry_run_message = format!("Would delete component {}.", self.component_id);
let outcome = guard_destructive_with_io(
&GuardOptions {
prompt: &prompt,
dry_run_message: &dry_run_message,
force: self.force,
dry_run: self.dry_run,
},
reader,
writer,
)?;
match outcome {
GuardOutcome::Cancelled | GuardOutcome::DryRun => return Ok(()),
GuardOutcome::Proceed => {}
}
}
client
.delete_component(&self.component_id, self.move_issues_to.as_deref())
.await?;
writeln!(writer, "Deleted component {}.", self.component_id)?;
Ok(())
}
}
fn print_components(components: &[JiraComponent]) {
if components.is_empty() {
println!("No components.");
return;
}
println!("{:<12} {:<30} DESCRIPTION", "ID", "NAME");
for c in components {
println!(
"{:<12} {:<30} {}",
c.id,
c.name,
c.description.as_deref().unwrap_or("")
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[test]
fn component_command_list_variant() {
let cmd = ComponentCommand {
command: ComponentSubcommands::List(ListCommand {
project: "PROJ".to_string(),
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, ComponentSubcommands::List(_)));
}
#[test]
fn component_command_delete_variant() {
let cmd = ComponentCommand {
command: ComponentSubcommands::Delete(DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: false,
dry_run: false,
}),
};
assert!(matches!(cmd.command, ComponentSubcommands::Delete(_)));
}
#[test]
fn print_components_empty_and_populated() {
print_components(&[]);
print_components(&[JiraComponent {
id: "1".to_string(),
name: "Backend".to_string(),
description: Some("Server".to_string()),
}]);
}
#[tokio::test]
async fn update_with_no_fields_errors_before_client() {
let cmd = UpdateCommand {
component_id: "10000".to_string(),
name: None,
description: None,
};
let err = cmd.execute().await.unwrap_err();
assert!(err.to_string().contains("Nothing to update"));
}
#[tokio::test]
async fn delete_component_force_calls_delete() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/component/10000"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
assert!(String::from_utf8(output)
.unwrap()
.contains("Deleted component 10000."));
}
#[tokio::test]
async fn delete_component_dry_run_makes_no_api_call() {
let client = mock_client("http://127.0.0.1:1");
let cmd = DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Would delete component 10000."));
assert!(!out.contains("Deleted component"));
}
#[tokio::test]
async fn list_execute_drives_create_client_and_gets() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/rest/api/3/project/PROJ/components",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"id": "10000", "name": "Backend", "description": "Server"}
])),
)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
ListCommand {
project: "PROJ".to_string(),
output: OutputFormat::Table,
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn create_execute_drives_create_client_and_posts() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/component"))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "10000", "name": "Backend"
})),
)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
ComponentCommand {
command: ComponentSubcommands::Create(CreateCommand {
project: "PROJ".to_string(),
name: "Backend".to_string(),
description: Some("Server side".to_string()),
}),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn update_execute_drives_create_client_and_puts() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/api/3/component/10000"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "10000", "name": "Renamed"
})),
)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
ComponentCommand {
command: ComponentSubcommands::Update(UpdateCommand {
component_id: "10000".to_string(),
name: Some("Renamed".to_string()),
description: Some("New desc".to_string()),
}),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn delete_execute_wrapper_drives_create_client() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/component/10000"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
ComponentCommand {
command: ComponentSubcommands::Delete(DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: true,
dry_run: false,
}),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn delete_component_prompt_yes_calls_delete() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/rest/api/3/component/10000"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let cmd = DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: false,
dry_run: false,
};
let mut input = std::io::Cursor::new(b"y\n".to_vec());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&client, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Delete component 10000?"));
assert!(out.contains("Deleted component 10000."));
}
#[tokio::test]
async fn delete_component_dry_run_propagates_guard_error() {
use crate::test_support::failing_io::FailingWriter;
let client = mock_client("http://127.0.0.1:1");
let cmd = DeleteCommand {
component_id: "10000".to_string(),
move_issues_to: None,
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut writer = FailingWriter;
let err = cmd
.execute_with_io(&client, &mut input, &mut writer)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
}