use crate::cicd::budget::{BudgetResult, BudgetStatus};
use crate::cicd::history::RegressionResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonOutput {
pub success: bool,
pub size: SizeInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<BudgetInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub regression: Option<RegressionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SizeInfo {
pub bytes: u64,
pub kb: f64,
pub mb: f64,
pub formatted: String,
}
impl SizeInfo {
pub fn new(bytes: u64) -> Self {
let kb = bytes as f64 / 1024.0;
let mb = kb / 1024.0;
let formatted = if mb >= 1.0 {
format!("{:.2} MB", mb)
} else {
format!("{:.2} KB", kb)
};
Self {
bytes,
kb,
mb,
formatted,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetInfo {
pub status: String,
pub passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_kb: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warn_threshold_kb: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_size_kb: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_kb: Option<f64>,
pub message: String,
}
impl BudgetInfo {
pub fn from_result(result: &BudgetResult) -> Self {
let status_str = match result.status {
BudgetStatus::UnderTarget => "under_target",
BudgetStatus::AboveTarget => "above_target",
BudgetStatus::Warning => "warning",
BudgetStatus::OverBudget => "over_budget",
};
let passed = result.status != BudgetStatus::OverBudget;
let delta_kb = if let Some(max) = result.max_size_kb {
Some(result.size_kb - max as f64)
} else if let Some(warn) = result.warn_threshold_kb {
Some(result.size_kb - warn as f64)
} else {
result
.target_kb
.map(|target| result.size_kb - target as f64)
};
Self {
status: status_str.to_string(),
passed,
target_kb: result.target_kb,
warn_threshold_kb: result.warn_threshold_kb,
max_size_kb: result.max_size_kb,
delta_kb,
message: result.message.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegressionInfo {
pub is_regression: bool,
pub previous_bytes: u64,
pub previous_kb: f64,
pub diff_bytes: i64,
pub diff_kb: f64,
pub percent_change: f64,
}
impl RegressionInfo {
pub fn from_result(result: &RegressionResult) -> Self {
Self {
is_regression: result.is_regression,
previous_bytes: result.previous_size,
previous_kb: result.previous_size as f64 / 1024.0,
diff_bytes: result.size_diff,
diff_kb: result.size_diff as f64 / 1024.0,
percent_change: result.percent_change,
}
}
}
impl JsonOutput {
pub fn new(size_bytes: u64) -> Self {
Self {
success: true,
size: SizeInfo::new(size_bytes),
budget: None,
regression: None,
}
}
pub fn with_budget(mut self, result: &BudgetResult) -> Self {
self.success = result.status != BudgetStatus::OverBudget;
self.budget = Some(BudgetInfo::from_result(result));
self
}
pub fn with_regression(mut self, result: &RegressionResult) -> Self {
self.regression = Some(RegressionInfo::from_result(result));
self
}
pub fn to_json(&self) -> anyhow::Result<String> {
serde_json::to_string_pretty(self)
.map_err(|e| anyhow::anyhow!("Failed to serialize JSON output: {}", e))
}
pub fn print(&self) {
match self.to_json() {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Error formatting JSON: {}", e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cicd::budget::BudgetChecker;
use crate::config::file::SizeBudget;
#[test]
fn test_size_info_formats_sizes_correctly() {
let info = SizeInfo::new(500 * 1024);
assert_eq!(info.bytes, 500 * 1024);
assert_eq!(info.kb, 500.0);
assert!(info.formatted.contains("500"));
assert!(info.formatted.contains("KB"));
}
#[test]
fn test_json_output_basic_includes_size_info() {
let output = JsonOutput::new(500 * 1024);
assert!(output.success);
assert_eq!(output.size.kb, 500.0);
assert!(output.budget.is_none());
assert!(output.regression.is_none());
}
#[test]
fn test_json_output_with_budget_includes_budget_status() {
let budget = SizeBudget {
max_size_kb: Some(1000),
warn_threshold_kb: Some(800),
target_size_kb: Some(500),
};
let checker = BudgetChecker::new(budget);
let budget_result = checker.check(600 * 1024).unwrap();
let output = JsonOutput::new(600 * 1024).with_budget(&budget_result);
assert!(output.success);
assert!(output.budget.is_some());
let budget_info = output.budget.unwrap();
assert_eq!(budget_info.status, "above_target");
assert!(budget_info.passed);
}
#[test]
fn test_json_output_with_budget_failed_shows_failure() {
let budget = SizeBudget {
max_size_kb: Some(500),
warn_threshold_kb: None,
target_size_kb: None,
};
let checker = BudgetChecker::new(budget);
let budget_result = checker.check(600 * 1024).unwrap();
let output = JsonOutput::new(600 * 1024).with_budget(&budget_result);
assert!(!output.success);
assert!(output.budget.is_some());
let budget_info = output.budget.unwrap();
assert_eq!(budget_info.status, "over_budget");
assert!(!budget_info.passed);
}
#[test]
fn test_json_output_serialization_produces_valid_json() {
let output = JsonOutput::new(500 * 1024);
let json = output.to_json().unwrap();
assert!(json.contains("\"success\":"));
assert!(json.contains("\"size\":"));
assert!(json.contains("\"kb\": 500"));
}
#[test]
fn test_size_info_formats_mb_for_large_sizes() {
let info = SizeInfo::new(2 * 1024 * 1024); assert_eq!(info.bytes, 2 * 1024 * 1024);
assert!(info.formatted.contains("MB"));
assert!(info.formatted.contains("2.00"));
}
#[test]
fn test_budget_info_from_result_with_over_budget() {
let budget = SizeBudget {
target_size_kb: Some(100),
warn_threshold_kb: None,
max_size_kb: Some(150),
};
let checker = BudgetChecker::new(budget);
let result = checker.check(200 * 1024).unwrap(); let info = BudgetInfo::from_result(&result);
assert_eq!(info.status, "over_budget");
assert!(!info.passed);
}
#[test]
fn test_budget_info_from_result_with_warning() {
let budget = SizeBudget {
target_size_kb: Some(100),
warn_threshold_kb: Some(120),
max_size_kb: Some(150),
};
let checker = BudgetChecker::new(budget);
let result = checker.check(130 * 1024).unwrap(); let info = BudgetInfo::from_result(&result);
assert_eq!(info.status, "warning");
assert!(info.passed);
}
#[test]
fn test_budget_info_from_result_with_above_target() {
let budget = SizeBudget {
target_size_kb: Some(100),
warn_threshold_kb: None,
max_size_kb: Some(150),
};
let checker = BudgetChecker::new(budget);
let result = checker.check(110 * 1024).unwrap(); let info = BudgetInfo::from_result(&result);
assert_eq!(info.status, "above_target");
assert!(info.passed);
}
#[test]
fn test_regression_info_from_result() {
use crate::cicd::history::RegressionResult;
let result = RegressionResult {
is_regression: true,
previous_size: 100 * 1024,
current_size: 110 * 1024,
size_diff: 10 * 1024,
percent_change: 10.0,
};
let info = RegressionInfo::from_result(&result);
assert!(info.is_regression);
assert_eq!(info.previous_bytes, 100 * 1024);
assert_eq!(info.diff_bytes, 10 * 1024);
assert_eq!(info.percent_change, 10.0);
}
#[test]
fn test_json_output_print_does_not_panic() {
let output = JsonOutput::new(500 * 1024);
output.print();
}
}