omni-dev 0.23.1

A powerful Git commit message analysis and amendment toolkit
Documentation
//! CLI commands for listing JIRA projects.

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;

/// Manages JIRA projects.
#[derive(Parser)]
pub struct ProjectCommand {
    /// The project subcommand to execute.
    #[command(subcommand)]
    pub command: ProjectSubcommands,
}

/// Project subcommands.
#[derive(Subcommand)]
pub enum ProjectSubcommands {
    /// Lists all accessible JIRA projects.
    List(ListCommand),
}

impl ProjectCommand {
    /// Executes the project command.
    pub async fn execute(self) -> Result<()> {
        match self.command {
            ProjectSubcommands::List(cmd) => cmd.execute().await,
        }
    }
}

/// Lists all accessible JIRA projects.
#[derive(Parser)]
pub struct ListCommand {
    /// Maximum number of results, 0 for unlimited (default: 50).
    #[arg(long, default_value_t = 50)]
    pub limit: u32,

    /// Output format.
    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
    pub output: OutputFormat,
}

impl ListCommand {
    /// Fetches and displays projects.
    pub async fn execute(self) -> Result<()> {
        let (client, _instance_url) = create_client()?;
        run_list_projects(&client, self.limit, &self.output).await
    }
}

/// Fetches and displays projects.
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(())
}

/// Prints projects as a formatted table.
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),
        }
    }

    // ── print_projects ─────────────────────────────────────────────

    #[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);
    }

    // ── run_list_projects ──────────────────────────────────────────

    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"));
    }

    // ── dispatch ───────────────────────────────────────────────────

    #[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);
    }
}