use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::client::{AtlassianClient, JiraProjectList};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct ProjectCommand {
#[command(subcommand)]
pub command: ProjectSubcommands,
}
#[derive(Subcommand)]
pub enum ProjectSubcommands {
List(ListCommand),
}
impl ProjectCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
ProjectSubcommands::List(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[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_projects(&client, self.limit, &self.output).await
}
}
async fn run_list_projects(
client: &AtlassianClient,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let result = client.get_projects(limit).await?;
if output_as(&result, output)? {
return Ok(());
}
print_projects(&result);
Ok(())
}
fn print_projects(result: &JiraProjectList) {
if result.projects.is_empty() {
println!("No projects found.");
return;
}
let key_width = result
.projects
.iter()
.map(|p| p.key.len())
.max()
.unwrap_or(3)
.max(3);
let type_width = result
.projects
.iter()
.filter_map(|p| p.project_type.as_ref().map(String::len))
.max()
.unwrap_or(4)
.max(4);
let lead_width = result
.projects
.iter()
.filter_map(|p| p.lead.as_ref().map(String::len))
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<key_width$} {:<type_width$} {:<lead_width$} NAME",
"KEY", "TYPE", "LEAD"
);
let name_sep = "-".repeat(4);
println!(
"{:<key_width$} {:<type_width$} {:<lead_width$} {name_sep}",
"-".repeat(key_width),
"-".repeat(type_width),
"-".repeat(lead_width),
);
for project in &result.projects {
let ptype = project.project_type.as_deref().unwrap_or("-");
let lead = project.lead.as_deref().unwrap_or("-");
println!(
"{:<key_width$} {:<type_width$} {:<lead_width$} {}",
project.key, ptype, lead, project.name
);
}
if result.total > result.projects.len() as u32 {
println!(
"\nShowing {} of {} projects.",
result.projects.len(),
result.total
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::client::JiraProject;
fn sample_project(
key: &str,
name: &str,
ptype: Option<&str>,
lead: Option<&str>,
) -> JiraProject {
JiraProject {
id: "1".to_string(),
key: key.to_string(),
name: name.to_string(),
project_type: ptype.map(String::from),
lead: lead.map(String::from),
}
}
#[test]
fn print_projects_empty() {
let result = JiraProjectList {
projects: vec![],
total: 0,
};
print_projects(&result);
}
#[test]
fn print_projects_with_data() {
let result = JiraProjectList {
projects: vec![
sample_project("PROJ", "My Project", Some("software"), Some("Alice")),
sample_project("OPS", "Operations", Some("business"), None),
],
total: 2,
};
print_projects(&result);
}
#[test]
fn print_projects_with_pagination() {
let result = JiraProjectList {
projects: vec![sample_project(
"PROJ",
"My Project",
Some("software"),
Some("Alice"),
)],
total: 100,
};
print_projects(&result);
}
#[test]
fn print_projects_all_fields_none() {
let result = JiraProjectList {
projects: vec![sample_project("X", "Minimal", None, None)],
total: 1,
};
print_projects(&result);
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_projects_table_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"id": "1", "key": "PROJ", "name": "Project", "projectTypeKey": "software"}],
"total": 1
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_projects(&client, 50, &OutputFormat::Table)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_projects_json_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"values": [], "total": 0})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_projects(&client, 50, &OutputFormat::Json)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_projects_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/project/search"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_projects(&client, 50, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[test]
fn project_command_list_variant() {
let cmd = ProjectCommand {
command: ProjectSubcommands::List(ListCommand {
limit: 50,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, ProjectSubcommands::List(_)));
}
#[test]
fn list_command_defaults() {
let cmd = ListCommand {
limit: 50,
output: OutputFormat::Table,
};
assert_eq!(cmd.limit, 50);
}
}