bzr 0.1.1

A CLI for Bugzilla, inspired by gh
Documentation
use std::fmt::Write as _;

use tabled::{Table, Tabled};

use super::formatting::{print_formatted, truncate};
use crate::types::{OutputFormat, Product};

fn format_named_list(heading: &str, items: &[(impl AsRef<str>, bool)]) -> String {
    if items.is_empty() {
        return String::new();
    }
    let mut output = format!("{heading}:\n");
    for (name, is_active) in items {
        let active = if *is_active { "" } else { " [inactive]" };
        let _ = writeln!(output, "  {}{active}", name.as_ref());
    }
    output.push('\n');
    output
}

fn format_product_detail(product: &Product) -> String {
    let mut output = format!("Product {}\n{}\n\n", product.name, product.description);
    if !product.components.is_empty() {
        output.push_str("Components:\n");
        for c in &product.components {
            let assignee = c.default_assignee.as_deref().unwrap_or("-");
            let active = if c.is_active { "" } else { " [inactive]" };
            let _ = writeln!(output, "  {}{active}  (assignee: {assignee})", c.name);
        }
        output.push('\n');
    }
    let versions: Vec<_> = product
        .versions
        .iter()
        .map(|v| (v.name.as_str(), v.is_active))
        .collect();
    output.push_str(&format_named_list("Versions", &versions));
    let milestones: Vec<_> = product
        .milestones
        .iter()
        .map(|m| (m.name.as_str(), m.is_active))
        .collect();
    output.push_str(&format_named_list("Milestones", &milestones));
    output
}

#[derive(Tabled)]
struct ProductRow {
    #[tabled(rename = "ID")]
    id: u64,
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "DESCRIPTION")]
    description: String,
    #[tabled(rename = "COMPONENTS")]
    components: usize,
}

#[expect(clippy::print_stdout)]
pub fn print_products(products: &[Product], format: OutputFormat) {
    print_formatted(products, format, |products| {
        if products.is_empty() {
            println!("No products found.");
            return;
        }
        let rows: Vec<ProductRow> = products
            .iter()
            .map(|p| {
                let description = truncate(&p.description, 60);
                ProductRow {
                    id: p.id,
                    name: p.name.clone(),
                    description,
                    components: p.components.len(),
                }
            })
            .collect();
        println!("{}", Table::new(rows));
    });
}

#[expect(clippy::print_stdout)]
pub fn print_product_detail(product: &Product, format: OutputFormat) {
    print_formatted(product, format, |product| {
        print!("{}", format_product_detail(product));
    });
}

#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::types::Product;
    use tabled::Table;

    fn make_product(id: u64, name: &str) -> Product {
        Product {
            id,
            name: name.into(),
            description: "A test product description".into(),
            is_active: true,
            components: vec![crate::types::Component {
                id: 1,
                name: "General".into(),
                description: "General component".into(),
                is_active: true,
                default_assignee: Some("dev@example.com".into()),
            }],
            versions: vec![crate::types::Version {
                id: 1,
                name: "1.0".into(),
                sort_key: 0,
                is_active: true,
            }],
            milestones: vec![crate::types::Milestone {
                id: 1,
                name: "M1".into(),
                sort_key: 0,
                is_active: true,
            }],
        }
    }

    // ── print_products ───────────────────────────────────────────────

    #[test]
    fn print_products_json_empty() {
        let products: Vec<Product> = vec![];
        let json = serde_json::to_string_pretty(&products).unwrap();
        assert_eq!(json, "[]");
    }

    #[test]
    fn print_products_json_one_product() {
        let products = vec![make_product(1, "Widget")];
        let json = serde_json::to_string_pretty(&products).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed[0]["id"], 1);
        assert_eq!(parsed[0]["name"], "Widget");
        assert!(parsed[0]["components"].is_array());
    }

    #[test]
    fn product_row_conversion() {
        let product = make_product(5, "Gadget");
        let row = ProductRow {
            id: product.id,
            name: product.name.clone(),
            description: truncate(&product.description, 60),
            components: product.components.len(),
        };
        let table = Table::new(vec![row]).to_string();
        assert!(table.contains('5'));
        assert!(table.contains("Gadget"));
        assert!(table.contains('1')); // 1 component
    }

    // ── print_product_detail ─────────────────────────────────────────

    #[test]
    fn print_product_detail_json() {
        let product = make_product(3, "Acme");
        let json = serde_json::to_string_pretty(&product).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["id"], 3);
        assert_eq!(parsed["name"], "Acme");
        assert!(!parsed["components"].as_array().unwrap().is_empty());
        assert!(!parsed["versions"].as_array().unwrap().is_empty());
        assert!(!parsed["milestones"].as_array().unwrap().is_empty());
    }

    #[test]
    fn format_named_list_empty_is_blank() {
        let empty: [(&str, bool); 0] = [];
        assert!(format_named_list("Versions", &empty).is_empty());
    }

    #[test]
    fn format_product_detail_renders_sections_and_inactive_flags() {
        let mut product = make_product(3, "Acme");
        product.components.push(crate::types::Component {
            id: 2,
            name: "Inactive".into(),
            description: "Inactive component".into(),
            is_active: false,
            default_assignee: None,
        });
        product.versions.push(crate::types::Version {
            id: 2,
            name: "2.0".into(),
            sort_key: 1,
            is_active: false,
        });
        product.milestones.push(crate::types::Milestone {
            id: 2,
            name: "M2".into(),
            sort_key: 1,
            is_active: false,
        });

        let output = format_product_detail(&product);

        assert!(output.contains("Product"));
        assert!(output.contains("Acme"));
        assert!(output.contains("Components:"));
        assert!(output.contains("General  (assignee: dev@example.com)"));
        assert!(output.contains("Inactive [inactive]  (assignee: -)"));
        assert!(output.contains("Versions:"));
        assert!(output.contains("1.0"));
        assert!(output.contains("2.0 [inactive]"));
        assert!(output.contains("Milestones:"));
        assert!(output.contains("M1"));
        assert!(output.contains("M2 [inactive]"));
    }
}