cargo-aprz-lib 0.2.0

Internal library for cargo-aprz
Documentation
use super::{ReportableCrate, common};
use crate::Result;
use crate::expr::EvaluationOutcome;
use crate::metrics::{Metric, MetricCategory, MetricValue};
use rust_xlsxwriter::{Color, DocProperties, Format, FormatAlign, Workbook};
use std::io::Write;
use strum::IntoEnumIterator;

#[expect(unused_results, reason = "rust_xlsxwriter methods return &mut Worksheet for chaining")]
pub fn generate<W: Write>(crates: &[ReportableCrate], writer: &mut W) -> Result<()> {
    let mut workbook = Workbook::new();

    // Set document properties
    let properties = DocProperties::new().set_author("cargo-aprz");
    workbook.set_properties(&properties);

    let worksheet = workbook.add_worksheet().set_name("Crate Metrics")?;

    // Create formats
    let bold_format = Format::new().set_bold();
    let category_format = Format::new()
        .set_bold()
        .set_background_color(Color::RGB(0x00FE_D7AA))
        .set_align(FormatAlign::Left);
    let left_align_format = Format::new().set_align(FormatAlign::Left);
    let acceptable_format = Format::new()
        .set_background_color(Color::RGB(0x00C8_E6C9))
        .set_font_color(Color::RGB(0x002E_7D32))
        .set_bold();
    let not_acceptable_format = Format::new()
        .set_background_color(Color::RGB(0x00FF_CDD2))
        .set_font_color(Color::RGB(0x00C6_2828))
        .set_bold();

    // Write crate names as column headers (starting from column B)
    for (col_idx, crate_info) in crates.iter().enumerate() {
        let header = format!("{} v{}", crate_info.name, crate_info.version);
        #[expect(clippy::cast_possible_truncation, reason = "Column index limited by Excel's u16 column limit")]
        worksheet.write_string_with_format(0, (col_idx + 1) as u16, &header, &bold_format)?;
    }

    // Freeze the first column (metric names) and first row (headers)
    worksheet.set_freeze_panes(1, 1)?;

    // Cache metrics for each crate
    let crate_metrics: Vec<&[Metric]> = crates.iter().map(|c| c.metrics.as_slice()).collect();

    // Group metrics by category across all crates
    let metrics_by_category = common::group_all_metrics_by_category(&crate_metrics);

    // Write metrics as rows, grouped by category
    let mut row = 1;

    // Add ranking status rows if any crate has an evaluation
    let has_evaluations = crates.iter().any(|c| c.evaluation.is_some());
    if has_evaluations {
        // Result row with colored cells
        worksheet.write_string_with_format(row, 0, "Evaluation Result", &bold_format)?;
        for (col_idx, crate_info) in crates.iter().enumerate() {
            if let Some(eval) = &crate_info.evaluation {
                let value = common::format_acceptance_status(eval.accepted);
                let format = if eval.accepted {
                    &acceptable_format
                } else {
                    &not_acceptable_format
                };
                #[expect(clippy::cast_possible_truncation, reason = "Column index limited by Excel's u16 column limit")]
                worksheet.write_string_with_format(row, (col_idx + 1) as u16, value, format)?;
            }
        }
        row += 1;

        // Reasons row
        worksheet.write_string_with_format(row, 0, "Reasons", &bold_format)?;
        write_eval_row(worksheet, row, crates, |eval| eval.reasons.join("; "))?;
        row += 1;

        // Add blank row after ranking
        row += 1;
    }

    // Write metrics grouped by category
    for category in MetricCategory::iter() {
        if let Some(category_metric_names) = metrics_by_category.get(&category) {
            // Write category header (uppercase and bold with background color)
            worksheet.write_string_with_format(row, 0, format!("{category}").to_uppercase(), &category_format)?;

            // Fill the rest of the category row with the same background color
            #[expect(clippy::cast_possible_truncation, reason = "Column count is limited by Excel's u16 column limit")]
            for c in 1..=crates.len() as u16 {
                worksheet.write_blank(row, c, &category_format)?;
            }

            row += 1;

            // Write each metric in this category
            for metric_name in category_metric_names {
                worksheet.write_string(row, 0, metric_name)?;

                // Write values for each crate
                for (col_idx, metrics) in crate_metrics.iter().enumerate() {
                    if let Some(metric) = metrics.iter().find(|m| m.name() == metric_name.as_str())
                        && let Some(ref value) = metric.value
                    {
                        #[expect(clippy::cast_possible_truncation, reason = "Column index limited by Excel's u16 column limit")]
                        write_metric_value(worksheet, row, (col_idx + 1) as u16, metric_name, value, &left_align_format)?;
                    }
                }
                row += 1;
            }

            // Add blank row after category
            row += 1;
        }
    }

    // Auto-fit all columns
    worksheet.autofit();

    // Write workbook to output
    let data = workbook.save_to_buffer()?;
    writer.write_all(&data)?;

    Ok(())
}

#[expect(unused_results, reason = "rust_xlsxwriter methods return &mut Worksheet for chaining")]
#[expect(clippy::cast_precision_loss, reason = "Intentional conversion to f64 for Excel output")]
fn write_metric_value(
    worksheet: &mut rust_xlsxwriter::Worksheet,
    row: u32,
    col: u16,
    metric_name: &str,
    value: &MetricValue,
    format: &Format,
) -> Result<()> {
    match value {
        MetricValue::UInt(u) => {
            worksheet.write_number_with_format(row, col, *u as f64, format)?;
        }
        MetricValue::Float(f) => {
            worksheet.write_number_with_format(row, col, *f, format)?;
        }
        MetricValue::Boolean(b) => {
            worksheet.write_boolean_with_format(row, col, *b, format)?;
        }
        MetricValue::String(s) => {
            // Check if this is a URL
            if common::is_url(s.as_str()) {
                worksheet.write_url(row, col, s.as_str())?;
            }
            // Check if this is keywords or categories
            else if common::is_keywords_metric(metric_name) || common::is_categories_metric(metric_name) {
                // For keywords/categories, format with # prefix
                let formatted = common::format_keywords_or_categories_with_prefix(s.as_str());
                worksheet.write_string_with_format(row, col, formatted, format)?;
            } else {
                worksheet.write_string_with_format(row, col, s.as_str(), format)?;
            }
        }
        MetricValue::DateTime(dt) => {
            worksheet.write_string_with_format(row, col, dt.format("%Y-%m-%d").to_string(), format)?;
        }
        MetricValue::List(_) => {
            // Format list as comma-separated string
            let formatted = common::format_metric_value(value);
            worksheet.write_string_with_format(row, col, formatted, format)?;
        }
    }
    Ok(())
}

/// Helper function to write a ranking row (Status or Reasons)
#[expect(unused_results, reason = "rust_xlsxwriter methods return &mut Worksheet for chaining")]
fn write_eval_row<F>(worksheet: &mut rust_xlsxwriter::Worksheet, row: u32, crates: &[ReportableCrate], extract_value: F) -> Result<()>
where
    F: Fn(&EvaluationOutcome) -> String,
{
    for (col_idx, crate_info) in crates.iter().enumerate() {
        if let Some(eval) = &crate_info.evaluation {
            let value = extract_value(eval);
            #[expect(clippy::cast_possible_truncation, reason = "Column index limited by Excel's u16 column limit")]
            worksheet.write_string(row, (col_idx + 1) as u16, &value)?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::metrics::MetricDef;

    static NAME_DEF: MetricDef = MetricDef {
        name: "name",
        description: "Crate name",
        category: MetricCategory::Metadata,
        extractor: |_| None,
        default_value: || None,
    };

    static VERSION_DEF: MetricDef = MetricDef {
        name: "version",
        description: "Crate version",
        category: MetricCategory::Metadata,
        extractor: |_| None,
        default_value: || None,
    };

    fn create_test_crate(name: &str, version: &str, evaluation: Option<EvaluationOutcome>) -> ReportableCrate {
        let metrics = vec![
            Metric::with_value(&NAME_DEF, MetricValue::String(name.into())),
            Metric::with_value(&VERSION_DEF, MetricValue::String(version.into())),
        ];
        ReportableCrate::new(name.to_string(), version.parse().unwrap(), metrics, evaluation)
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_empty_crates() {
        let crates: Vec<ReportableCrate> = vec![];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        // Should generate a valid Excel file (has content)
        assert!(!output.is_empty());
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_single_crate_no_ranking() {
        let crates = vec![create_test_crate("test_crate", "1.2.3", None)];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        // Should generate a valid Excel file
        assert!(!output.is_empty());
        // Excel files start with PK (ZIP signature)
        assert_eq!(&output[0..2], b"PK");
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_single_crate_with_ranking() {
        let eval = EvaluationOutcome {
            accepted: true,
            reasons: vec!["Good".to_string()],
        };
        let crates = vec![create_test_crate("test_crate", "1.0.0", Some(eval))];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        assert!(!output.is_empty());
        assert_eq!(&output[0..2], b"PK");
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_multiple_crates() {
        let crates = vec![
            create_test_crate("crate_a", "1.0.0", None),
            create_test_crate("crate_b", "2.0.0", None),
        ];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        assert!(!output.is_empty());
        // Verify it's a valid ZIP/Excel file
        assert_eq!(&output[0..2], b"PK");
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_denied_status() {
        let eval = EvaluationOutcome {
            accepted: false,
            reasons: vec!["Security issue".to_string()],
        };
        let crates = vec![create_test_crate("bad_crate", "1.0.0", Some(eval))];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        assert!(!output.is_empty());
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_with_missing_data() {
        let crates = vec![create_test_crate("missing", "1.0.0", None)];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        // Should still generate valid file even with missing data
        assert!(!output.is_empty());
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot call GetSystemTimePreciseAsFileTime (rust_xlsxwriter)")]
    fn test_generate_mixed_found_and_missing() {
        let crates = vec![create_test_crate("good", "1.0.0", None), create_test_crate("bad", "1.0.0", None)];
        let mut output = Vec::new();
        let result = generate(&crates, &mut output);
        result.unwrap();
        assert!(!output.is_empty());
    }
}