use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::{AgileSprintList, AtlassianClient, JiraSearchResult};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct SprintCommand {
#[command(subcommand)]
pub command: SprintSubcommands,
}
#[derive(Subcommand)]
pub enum SprintSubcommands {
List(ListCommand),
Issues(IssuesCommand),
Add(AddCommand),
Create(CreateCommand),
Update(UpdateCommand),
}
impl SprintCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
SprintSubcommands::List(cmd) => cmd.execute().await,
SprintSubcommands::Issues(cmd) => cmd.execute().await,
SprintSubcommands::Add(cmd) => cmd.execute().await,
SprintSubcommands::Create(cmd) => cmd.execute().await,
SprintSubcommands::Update(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long)]
pub board_id: u64,
#[arg(long)]
pub state: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[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_sprints(
&client,
self.board_id,
self.state.as_deref(),
self.limit,
&self.output,
)
.await
}
}
#[derive(Parser)]
pub struct IssuesCommand {
#[arg(long)]
pub sprint_id: u64,
#[arg(long)]
pub jql: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: u32,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl IssuesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_sprint_issues(
&client,
self.sprint_id,
self.jql.as_deref(),
self.limit,
&self.output,
)
.await
}
}
#[derive(Parser)]
pub struct AddCommand {
#[arg(long)]
pub sprint_id: u64,
#[arg(long)]
pub issues: String,
}
impl AddCommand {
pub async fn execute(self) -> Result<()> {
let keys = parse_issue_keys(&self.issues);
if keys.is_empty() {
anyhow::bail!("No issue keys provided. Use --issues KEY1,KEY2,...");
}
let (client, _instance_url) = create_client()?;
run_add_to_sprint(&client, self.sprint_id, &keys).await
}
}
#[derive(Parser)]
pub struct CreateCommand {
#[arg(long)]
pub board_id: u64,
#[arg(long)]
pub name: String,
#[arg(long)]
pub start_date: Option<String>,
#[arg(long)]
pub end_date: Option<String>,
#[arg(long)]
pub goal: Option<String>,
}
impl CreateCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_create_sprint(
&client,
self.board_id,
&self.name,
self.start_date.as_deref(),
self.end_date.as_deref(),
self.goal.as_deref(),
)
.await
}
}
#[derive(Parser)]
pub struct UpdateCommand {
#[arg(long)]
pub sprint_id: u64,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub state: Option<String>,
#[arg(long)]
pub start_date: Option<String>,
#[arg(long)]
pub end_date: Option<String>,
#[arg(long)]
pub goal: Option<String>,
}
impl UpdateCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_update_sprint(
&client,
self.sprint_id,
self.name.as_deref(),
self.state.as_deref(),
self.start_date.as_deref(),
self.end_date.as_deref(),
self.goal.as_deref(),
)
.await
}
}
async fn run_list_sprints(
client: &AtlassianClient,
board_id: u64,
state: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let result = client.get_sprints(board_id, state, limit).await?;
if output_as(&result, output)? {
return Ok(());
}
print_sprints(&result);
Ok(())
}
async fn run_sprint_issues(
client: &AtlassianClient,
sprint_id: u64,
jql: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let result = client.get_sprint_issues(sprint_id, jql, limit).await?;
if output_as(&result, output)? {
return Ok(());
}
print_sprint_issues(&result);
Ok(())
}
async fn run_add_to_sprint(
client: &AtlassianClient,
sprint_id: u64,
keys: &[String],
) -> Result<()> {
let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
client.add_issues_to_sprint(sprint_id, &key_refs).await?;
println!("Added {} issue(s) to sprint {sprint_id}.", keys.len());
Ok(())
}
async fn run_create_sprint(
client: &AtlassianClient,
board_id: u64,
name: &str,
start_date: Option<&str>,
end_date: Option<&str>,
goal: Option<&str>,
) -> Result<()> {
let sprint = client
.create_sprint(board_id, name, start_date, end_date, goal)
.await?;
println!("Created sprint {} (id: {}).", sprint.name, sprint.id);
Ok(())
}
async fn run_update_sprint(
client: &AtlassianClient,
sprint_id: u64,
name: Option<&str>,
state: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
goal: Option<&str>,
) -> Result<()> {
client
.update_sprint(sprint_id, name, state, start_date, end_date, goal)
.await?;
println!("Updated sprint {sprint_id}.");
Ok(())
}
fn parse_issue_keys(input: &str) -> Vec<String> {
input
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn print_sprints(result: &AgileSprintList) {
if result.sprints.is_empty() {
println!("No sprints found.");
return;
}
let id_width = result
.sprints
.iter()
.map(|s| s.id.to_string().len())
.max()
.unwrap_or(2)
.max(2);
let state_width = result
.sprints
.iter()
.map(|s| s.state.len())
.max()
.unwrap_or(5)
.max(5);
println!(
"{:<id_width$} {:<state_width$} START END NAME",
"ID", "STATE"
);
let name_sep = "-".repeat(4);
println!(
"{:<id_width$} {:<state_width$} ---------- ---------- {name_sep}",
"-".repeat(id_width),
"-".repeat(state_width),
);
for sprint in &result.sprints {
let start = format_date(sprint.start_date.as_deref());
let end = format_date(sprint.end_date.as_deref());
println!(
"{:<id_width$} {:<state_width$} {:<10} {:<10} {}",
sprint.id, sprint.state, start, end, sprint.name
);
}
if result.total > result.sprints.len() as u32 {
println!(
"\nShowing {} of {} sprints.",
result.sprints.len(),
result.total
);
}
}
fn format_date(date: Option<&str>) -> &str {
match date {
Some(d) if d.len() >= 10 => &d[..10],
Some(d) => d,
None => "-",
}
}
fn print_sprint_issues(result: &JiraSearchResult) {
if result.issues.is_empty() {
println!("No issues found.");
return;
}
let key_width = result
.issues
.iter()
.map(|i| i.key.len())
.max()
.unwrap_or(3)
.max(3);
let status_width = result
.issues
.iter()
.filter_map(|i| i.status.as_ref().map(String::len))
.max()
.unwrap_or(6)
.max(6);
let assignee_width = result
.issues
.iter()
.filter_map(|i| i.assignee.as_ref().map(String::len))
.max()
.unwrap_or(8)
.max(8);
let summary_sep = "-".repeat(7);
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} SUMMARY",
"KEY", "STATUS", "ASSIGNEE"
);
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} {summary_sep}",
"-".repeat(key_width),
"-".repeat(status_width),
"-".repeat(assignee_width),
);
for issue in &result.issues {
let status = issue.status.as_deref().unwrap_or("-");
let assignee = issue.assignee.as_deref().unwrap_or("-");
println!(
"{:<key_width$} {:<status_width$} {:<assignee_width$} {}",
issue.key, status, assignee, issue.summary
);
}
if result.total > result.issues.len() as u32 {
println!(
"\nShowing {} of {} issues.",
result.issues.len(),
result.total
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::client::{AgileSprint, JiraIssue};
fn sample_sprint(
id: u64,
name: &str,
state: &str,
start: Option<&str>,
end: Option<&str>,
goal: Option<&str>,
) -> AgileSprint {
AgileSprint {
id,
name: name.to_string(),
state: state.to_string(),
start_date: start.map(String::from),
end_date: end.map(String::from),
goal: goal.map(String::from),
}
}
fn sample_issue(
key: &str,
summary: &str,
status: Option<&str>,
assignee: Option<&str>,
) -> JiraIssue {
JiraIssue {
key: key.to_string(),
summary: summary.to_string(),
description_adf: None,
status: status.map(String::from),
issue_type: None,
assignee: assignee.map(String::from),
priority: None,
labels: vec![],
custom_fields: vec![],
}
}
#[test]
fn parse_keys_basic() {
let keys = parse_issue_keys("PROJ-1,PROJ-2,PROJ-3");
assert_eq!(keys, vec!["PROJ-1", "PROJ-2", "PROJ-3"]);
}
#[test]
fn parse_keys_with_whitespace() {
let keys = parse_issue_keys("PROJ-1, PROJ-2 , PROJ-3");
assert_eq!(keys, vec!["PROJ-1", "PROJ-2", "PROJ-3"]);
}
#[test]
fn parse_keys_single() {
let keys = parse_issue_keys("PROJ-1");
assert_eq!(keys, vec!["PROJ-1"]);
}
#[test]
fn parse_keys_empty() {
let keys = parse_issue_keys("");
assert!(keys.is_empty());
}
#[test]
fn parse_keys_trailing_comma() {
let keys = parse_issue_keys("PROJ-1,PROJ-2,");
assert_eq!(keys, vec!["PROJ-1", "PROJ-2"]);
}
#[test]
fn format_date_full_iso() {
assert_eq!(format_date(Some("2026-03-15T10:00:00.000Z")), "2026-03-15");
}
#[test]
fn format_date_just_date() {
assert_eq!(format_date(Some("2026-03-15")), "2026-03-15");
}
#[test]
fn format_date_short() {
assert_eq!(format_date(Some("2026")), "2026");
}
#[test]
fn format_date_none() {
assert_eq!(format_date(None), "-");
}
#[test]
fn print_sprints_empty() {
let result = AgileSprintList {
sprints: vec![],
total: 0,
};
print_sprints(&result);
}
#[test]
fn print_sprints_with_data() {
let result = AgileSprintList {
sprints: vec![
sample_sprint(
10,
"Sprint 1",
"closed",
Some("2026-03-01"),
Some("2026-03-14"),
Some("MVP"),
),
sample_sprint(11, "Sprint 2", "active", Some("2026-03-15"), None, None),
],
total: 2,
};
print_sprints(&result);
}
#[test]
fn print_sprints_with_pagination() {
let result = AgileSprintList {
sprints: vec![sample_sprint(10, "Sprint 1", "active", None, None, None)],
total: 50,
};
print_sprints(&result);
}
#[test]
fn print_sprint_issues_empty() {
let result = JiraSearchResult {
issues: vec![],
total: 0,
};
print_sprint_issues(&result);
}
#[test]
fn print_sprint_issues_with_data() {
let result = JiraSearchResult {
issues: vec![
sample_issue("PROJ-1", "Fix login", Some("Open"), Some("Alice")),
sample_issue("PROJ-2", "Add feature", None, None),
],
total: 2,
};
print_sprint_issues(&result);
}
#[test]
fn print_sprint_issues_with_pagination() {
let result = JiraSearchResult {
issues: vec![sample_issue("PROJ-1", "Issue", Some("Open"), None)],
total: 100,
};
print_sprint_issues(&result);
}
#[test]
fn sprint_command_list_variant() {
let cmd = SprintCommand {
command: SprintSubcommands::List(ListCommand {
board_id: 1,
state: None,
limit: 50,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, SprintSubcommands::List(_)));
}
#[test]
fn sprint_command_issues_variant() {
let cmd = SprintCommand {
command: SprintSubcommands::Issues(IssuesCommand {
sprint_id: 10,
jql: None,
limit: 50,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, SprintSubcommands::Issues(_)));
}
#[test]
fn sprint_command_add_variant() {
let cmd = SprintCommand {
command: SprintSubcommands::Add(AddCommand {
sprint_id: 10,
issues: "PROJ-1,PROJ-2".to_string(),
}),
};
assert!(matches!(cmd.command, SprintSubcommands::Add(_)));
}
#[test]
fn list_command_with_state() {
let cmd = ListCommand {
board_id: 1,
state: Some("active".to_string()),
limit: 25,
output: OutputFormat::Table,
};
assert_eq!(cmd.state.as_deref(), Some("active"));
}
#[test]
fn sprint_command_create_variant() {
let cmd = SprintCommand {
command: SprintSubcommands::Create(CreateCommand {
board_id: 1,
name: "Sprint 5".to_string(),
start_date: None,
end_date: None,
goal: None,
}),
};
assert!(matches!(cmd.command, SprintSubcommands::Create(_)));
}
#[test]
fn sprint_command_update_variant() {
let cmd = SprintCommand {
command: SprintSubcommands::Update(UpdateCommand {
sprint_id: 42,
name: Some("Updated".to_string()),
state: None,
start_date: None,
end_date: None,
goal: None,
}),
};
assert!(matches!(cmd.command, SprintSubcommands::Update(_)));
}
#[test]
fn create_command_all_fields() {
let cmd = CreateCommand {
board_id: 1,
name: "Sprint 5".to_string(),
start_date: Some("2026-05-01".to_string()),
end_date: Some("2026-05-14".to_string()),
goal: Some("Ship v2".to_string()),
};
assert_eq!(cmd.board_id, 1);
assert_eq!(cmd.name, "Sprint 5");
assert_eq!(cmd.goal.as_deref(), Some("Ship v2"));
}
#[test]
fn update_command_partial_fields() {
let cmd = UpdateCommand {
sprint_id: 42,
name: None,
state: Some("active".to_string()),
start_date: None,
end_date: None,
goal: Some("New goal".to_string()),
};
assert_eq!(cmd.sprint_id, 42);
assert!(cmd.name.is_none());
assert_eq!(cmd.state.as_deref(), Some("active"));
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_sprints_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/1/sprint"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": 10, "name": "Sprint 1", "state": "active"}],
"isLast": true
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_sprints(&client, 1, None, 50, &OutputFormat::Table)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_sprints_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/board/999/sprint"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_sprints(&client, 999, None, 50, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_sprint_issues_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [],
"total": 0
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_sprint_issues(&client, 10, None, 50, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_add_to_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/10/issue"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let keys = vec!["PROJ-1".to_string(), "PROJ-2".to_string()];
assert!(run_add_to_sprint(&client, 10, &keys).await.is_ok());
}
#[tokio::test]
async fn run_add_to_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/999/issue"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let keys = vec!["PROJ-1".to_string()];
let err = run_add_to_sprint(&client, 999, &keys).await.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_create_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 100, "name": "Sprint 5", "state": "future"
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_create_sprint(&client, 1, "Sprint 5", None, None, None)
.await
.is_ok());
}
#[tokio::test]
async fn run_create_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_create_sprint(&client, 1, "Sprint", None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn run_update_sprint_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/42"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 42, "name": "Updated", "state": "active"
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_update_sprint(&client, 42, Some("Updated"), None, None, None, None)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_update_sprint_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/rest/agile/1.0/sprint/999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_update_sprint(&client, 999, Some("X"), None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
}