use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnminifiedAsset {
pub url: String,
pub kind: String,
pub decoded_bytes: u64,
pub transfer_bytes: u64,
pub savings_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinificationAnalysis {
pub unminified_scripts: Vec<UnminifiedAsset>,
pub unminified_styles: Vec<UnminifiedAsset>,
pub total_savings_bytes: u64,
pub total_unminified_count: u32,
}
pub async fn analyze_minification(page: &Page) -> Result<MinificationAnalysis> {
info!("Analyzing unminified JS/CSS assets...");
let js = r#"
(() => {
var resources = performance.getEntriesByType('resource');
return JSON.stringify(resources.map(function(r) {
return {
url: r.name,
initiatorType: r.initiatorType || 'other',
decodedBodySize: r.decodedBodySize || 0,
transferSize: r.transferSize || 0
};
}));
})()
"#;
let result = page
.evaluate(js)
.await
.map_err(|e| AuditError::CdpError(format!("Minification analysis JS failed: {e}")))?;
let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]");
let entries: Vec<RawEntry> = serde_json::from_str(json_str).unwrap_or_default();
let mut unminified_scripts: Vec<UnminifiedAsset> = Vec::new();
let mut unminified_styles: Vec<UnminifiedAsset> = Vec::new();
for entry in entries {
let kind = classify_kind(&entry.url, &entry.initiator_type);
let kind = match kind.as_str() {
"script" | "css" => kind,
_ => continue,
};
if entry.url.starts_with("data:") || entry.url.starts_with("blob:") {
continue;
}
if entry.decoded_body_size < 10_000 {
continue;
}
let url_lower = entry.url.to_lowercase();
if url_lower.contains(".min.") || url_lower.contains("-min.") {
continue;
}
let is_likely_unminified = if entry.transfer_size > 0 {
let ratio = entry.decoded_body_size as f64 / entry.transfer_size as f64;
ratio >= 4.0
} else {
entry.decoded_body_size >= 100_000
};
if !is_likely_unminified {
continue;
}
let savings_bytes = entry
.decoded_body_size
.saturating_sub(entry.decoded_body_size / 3);
let asset = UnminifiedAsset {
url: truncate(&entry.url, 120),
kind: kind.clone(),
decoded_bytes: entry.decoded_body_size,
transfer_bytes: entry.transfer_size,
savings_bytes,
};
if kind == "script" {
unminified_scripts.push(asset);
} else {
unminified_styles.push(asset);
}
}
unminified_scripts.sort_by_key(|a| std::cmp::Reverse(a.savings_bytes));
unminified_styles.sort_by_key(|a| std::cmp::Reverse(a.savings_bytes));
let total_savings_bytes: u64 = unminified_scripts
.iter()
.chain(unminified_styles.iter())
.map(|a| a.savings_bytes)
.sum();
let total_unminified_count = (unminified_scripts.len() + unminified_styles.len()) as u32;
info!(
"Minification: {} unminified resources, {:.1} KB estimated savings",
total_unminified_count,
total_savings_bytes as f64 / 1024.0
);
Ok(MinificationAnalysis {
unminified_scripts,
unminified_styles,
total_savings_bytes,
total_unminified_count,
})
}
#[derive(Debug, serde::Deserialize)]
struct RawEntry {
url: String,
#[serde(rename = "initiatorType")]
initiator_type: String,
#[serde(rename = "decodedBodySize")]
decoded_body_size: u64,
#[serde(rename = "transferSize")]
transfer_size: u64,
}
fn classify_kind(url: &str, initiator_type: &str) -> String {
let url_lower = url.to_lowercase();
if url_lower.ends_with(".woff2")
|| url_lower.ends_with(".woff")
|| url_lower.ends_with(".ttf")
|| url_lower.ends_with(".eot")
|| url_lower.ends_with(".otf")
{
return "other".to_string();
}
if initiator_type == "script" || url_lower.ends_with(".js") || url_lower.contains(".js?") {
"script"
} else if initiator_type == "css" || url_lower.ends_with(".css") || url_lower.contains(".css?")
{
"css"
} else {
"other"
}
.to_string()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let boundary = s
.char_indices()
.take_while(|(i, _)| *i <= max.saturating_sub(3))
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}…", &s[..boundary])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_kind_script() {
assert_eq!(
classify_kind("https://cdn.example.com/app.js", "script"),
"script"
);
assert_eq!(
classify_kind("https://cdn.example.com/app.js", "other"),
"script"
);
}
#[test]
fn test_classify_kind_css() {
assert_eq!(
classify_kind("https://cdn.example.com/style.css", "css"),
"css"
);
assert_eq!(
classify_kind("https://cdn.example.com/style.css", "other"),
"css"
);
}
#[test]
fn test_classify_kind_other() {
assert_eq!(
classify_kind("https://cdn.example.com/image.png", "img"),
"other"
);
}
#[test]
fn test_truncate() {
let long = "a".repeat(200);
let result = truncate(&long, 120);
assert!(result.len() <= 123); }
}