use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::{JiraIssueLink, JiraLinkType};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct LinkCommand {
#[command(subcommand)]
pub command: LinkSubcommands,
}
#[derive(Subcommand)]
pub enum LinkSubcommands {
List(ListLinksCommand),
Types(TypesCommand),
Create(CreateLinkCommand),
Remove(RemoveLinkCommand),
Epic(EpicLinkCommand),
}
impl LinkCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
LinkSubcommands::List(cmd) => cmd.execute().await,
LinkSubcommands::Types(cmd) => cmd.execute().await,
LinkSubcommands::Create(cmd) => cmd.execute().await,
LinkSubcommands::Remove(cmd) => cmd.execute().await,
LinkSubcommands::Epic(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListLinksCommand {
pub key: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListLinksCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let links = client.get_issue_links(&self.key).await?;
if output_as(&links, &self.output)? {
return Ok(());
}
print_issue_links(&self.key, &links);
Ok(())
}
}
#[derive(Parser)]
pub struct TypesCommand {
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl TypesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let types = client.get_link_types().await?;
if output_as(&types, &self.output)? {
return Ok(());
}
print_link_types(&types);
Ok(())
}
}
#[derive(Parser)]
pub struct CreateLinkCommand {
#[arg(long, value_name = "TYPE")]
pub r#type: String,
#[arg(long)]
pub inward: String,
#[arg(long)]
pub outward: String,
}
impl CreateLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
client
.create_issue_link(&self.r#type, &self.inward, &self.outward)
.await?;
println!(
"Linked {} {} {} (type: {}).",
self.inward,
format_link_direction(&self.r#type),
self.outward,
self.r#type
);
Ok(())
}
}
#[derive(Parser)]
pub struct RemoveLinkCommand {
#[arg(long)]
pub link_id: String,
}
impl RemoveLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
client.remove_issue_link(&self.link_id).await?;
println!("Removed link {}.", self.link_id);
Ok(())
}
}
#[derive(Parser)]
pub struct EpicLinkCommand {
#[arg(long)]
pub epic: String,
#[arg(long)]
pub issue: String,
}
impl EpicLinkCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
client.link_to_epic(&self.epic, &self.issue).await?;
println!("Linked {} to epic {}.", self.issue, self.epic);
Ok(())
}
}
fn format_link_direction(type_name: &str) -> &str {
match type_name.to_lowercase().as_str() {
"relates to" | "relates" => "↔",
_ => "→",
}
}
fn print_issue_links(key: &str, links: &[JiraIssueLink]) {
if links.is_empty() {
println!("{key}: no links.");
return;
}
let id_width = links.iter().map(|l| l.id.len()).max().unwrap_or(2).max(2);
let type_width = links
.iter()
.map(|l| l.link_type.len())
.max()
.unwrap_or(4)
.max(4);
let dir_width = 7; let key_width = links
.iter()
.map(|l| l.linked_issue_key.len())
.max()
.unwrap_or(3)
.max(3);
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} SUMMARY",
"ID", "TYPE", "DIR", "KEY"
);
let summary_sep = "-".repeat(7);
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} {summary_sep}",
"-".repeat(id_width),
"-".repeat(type_width),
"-".repeat(dir_width),
"-".repeat(key_width),
);
for link in links {
println!(
"{:<id_width$} {:<type_width$} {:<dir_width$} {:<key_width$} {}",
link.id,
link.link_type,
link.direction,
link.linked_issue_key,
link.linked_issue_summary
);
}
}
fn print_link_types(types: &[JiraLinkType]) {
if types.is_empty() {
println!("No link types found.");
return;
}
let id_width = types.iter().map(|t| t.id.len()).max().unwrap_or(2).max(2);
let name_width = types.iter().map(|t| t.name.len()).max().unwrap_or(4).max(4);
let inward_width = types
.iter()
.map(|t| t.inward.len())
.max()
.unwrap_or(6)
.max(6);
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} OUTWARD",
"ID", "NAME", "INWARD"
);
let out_sep = "-".repeat(7);
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} {out_sep}",
"-".repeat(id_width),
"-".repeat(name_width),
"-".repeat(inward_width),
);
for t in types {
println!(
"{:<id_width$} {:<name_width$} {:<inward_width$} {}",
t.id, t.name, t.inward, t.outward
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_link_type(id: &str, name: &str, inward: &str, outward: &str) -> JiraLinkType {
JiraLinkType {
id: id.to_string(),
name: name.to_string(),
inward: inward.to_string(),
outward: outward.to_string(),
}
}
#[test]
fn format_direction_blocks() {
assert_eq!(format_link_direction("Blocks"), "→");
}
#[test]
fn format_direction_relates() {
assert_eq!(format_link_direction("Relates to"), "↔");
}
#[test]
fn format_direction_unknown() {
assert_eq!(format_link_direction("Custom Link"), "→");
}
#[test]
fn format_direction_case_insensitive() {
assert_eq!(format_link_direction("BLOCKS"), "→");
assert_eq!(format_link_direction("duplicates"), "→");
}
#[test]
fn print_types_empty() {
print_link_types(&[]);
}
#[test]
fn print_types_with_data() {
let types = vec![
sample_link_type("1", "Blocks", "is blocked by", "blocks"),
sample_link_type("2", "Clones", "is cloned by", "clones"),
];
print_link_types(&types);
}
fn sample_link(
id: &str,
link_type: &str,
direction: &str,
key: &str,
summary: &str,
) -> JiraIssueLink {
JiraIssueLink {
id: id.to_string(),
link_type: link_type.to_string(),
direction: direction.to_string(),
linked_issue_key: key.to_string(),
linked_issue_summary: summary.to_string(),
}
}
#[test]
fn print_issue_links_empty() {
print_issue_links("PROJ-1", &[]);
}
#[test]
fn print_issue_links_with_data() {
let links = vec![
sample_link("100", "Blocks", "outward", "PROJ-2", "Blocked issue"),
sample_link("101", "Relates", "inward", "PROJ-3", "Related issue"),
];
print_issue_links("PROJ-1", &links);
}
#[test]
fn link_command_list_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::List(ListLinksCommand {
key: "PROJ-1".to_string(),
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LinkSubcommands::List(_)));
}
#[test]
fn link_command_types_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Types(TypesCommand {
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Types(_)));
}
#[test]
fn link_command_create_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Create(CreateLinkCommand {
r#type: "Blocks".to_string(),
inward: "PROJ-1".to_string(),
outward: "PROJ-2".to_string(),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Create(_)));
}
#[test]
fn link_command_remove_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Remove(RemoveLinkCommand {
link_id: "12345".to_string(),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Remove(_)));
}
#[test]
fn link_command_epic_variant() {
let cmd = LinkCommand {
command: LinkSubcommands::Epic(EpicLinkCommand {
epic: "EPIC-1".to_string(),
issue: "PROJ-2".to_string(),
}),
};
assert!(matches!(cmd.command, LinkSubcommands::Epic(_)));
}
#[test]
fn create_link_command_fields() {
let cmd = CreateLinkCommand {
r#type: "Blocks".to_string(),
inward: "PROJ-1".to_string(),
outward: "PROJ-2".to_string(),
};
assert_eq!(cmd.r#type, "Blocks");
assert_eq!(cmd.inward, "PROJ-1");
assert_eq!(cmd.outward, "PROJ-2");
}
#[test]
fn epic_link_command_fields() {
let cmd = EpicLinkCommand {
epic: "EPIC-1".to_string(),
issue: "STORY-1".to_string(),
};
assert_eq!(cmd.epic, "EPIC-1");
assert_eq!(cmd.issue, "STORY-1");
}
}