1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ClassAnalyzer {
13 pub used_classes: HashSet<String>,
15 pub unused_classes: HashSet<String>,
17 pub dependencies: HashMap<String, HashSet<String>>,
19 pub critical_classes: HashSet<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct CssPurger {
26 pub keep_classes: HashSet<String>,
28 pub remove_classes: HashSet<String>,
30 pub keep_rules: HashSet<String>,
32 pub remove_rules: HashSet<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct OptimizationResult {
39 pub original_size: usize,
41 pub optimized_size: usize,
43 pub reduction_percentage: f32,
45 pub classes_removed: usize,
47 pub rules_removed: usize,
49 pub warnings: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct BundleAnalyzer {
56 pub class_stats: HashMap<String, ClassUsageStats>,
58 pub rule_stats: HashMap<String, RuleUsageStats>,
60 pub metrics: PerformanceMetrics,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ClassUsageStats {
67 pub usage_count: u32,
69 pub used_in_files: HashSet<String>,
71 pub is_critical: bool,
73 pub dependencies: HashSet<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct RuleUsageStats {
80 pub usage_count: u32,
82 pub selectors: HashSet<String>,
84 pub properties: HashSet<String>,
86 pub size_bytes: usize,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct PerformanceMetrics {
93 pub total_size: usize,
95 pub class_count: usize,
97 pub rule_count: usize,
99 pub avg_class_size: f32,
101 pub avg_rule_size: f32,
103 pub compression_ratio: f32,
105}
106
107impl ClassAnalyzer {
108 pub fn new() -> Self {
110 Self {
111 used_classes: HashSet::new(),
112 unused_classes: HashSet::new(),
113 dependencies: HashMap::new(),
114 critical_classes: HashSet::new(),
115 }
116 }
117
118 pub fn add_used_class(&mut self, class: String) {
120 self.used_classes.insert(class);
121 }
122
123 pub fn add_used_classes(&mut self, classes: Vec<String>) {
125 for class in classes {
126 self.used_classes.insert(class);
127 }
128 }
129
130 pub fn add_dependency(&mut self, class: String, dependency: String) {
132 self.dependencies
133 .entry(class)
134 .or_insert_with(HashSet::new)
135 .insert(dependency);
136 }
137
138 pub fn mark_critical(&mut self, class: String) {
140 self.critical_classes.insert(class);
141 }
142
143 pub fn analyze_usage(&mut self, all_classes: HashSet<String>) {
145 self.unused_classes = all_classes
147 .difference(&self.used_classes)
148 .cloned()
149 .collect();
150
151 self.unused_classes = self
153 .unused_classes
154 .difference(&self.critical_classes)
155 .cloned()
156 .collect();
157
158 let mut to_check = self.used_classes.clone();
160 while !to_check.is_empty() {
161 let mut new_dependencies = HashSet::new();
162 for class in &to_check {
163 if let Some(deps) = self.dependencies.get(class) {
164 for dep in deps {
165 if !self.used_classes.contains(dep) {
166 new_dependencies.insert(dep.clone());
167 self.used_classes.insert(dep.clone());
168 }
169 }
170 }
171 }
172 to_check = new_dependencies;
173 }
174 }
175
176 pub fn get_optimization_suggestions(&self) -> Vec<String> {
178 let mut suggestions = Vec::new();
179
180 if !self.unused_classes.is_empty() {
181 suggestions.push(format!(
182 "Remove {} unused classes to reduce bundle size",
183 self.unused_classes.len()
184 ));
185 }
186
187 let critical_count = self.critical_classes.len();
188 if critical_count > 0 {
189 suggestions.push(format!(
190 "{} critical classes are protected from removal",
191 critical_count
192 ));
193 }
194
195 let dependency_count: usize = self.dependencies.values().map(|deps| deps.len()).sum();
196 if dependency_count > 0 {
197 suggestions.push(format!(
198 "Found {} class dependencies that may affect optimization",
199 dependency_count
200 ));
201 }
202
203 suggestions
204 }
205}
206
207impl CssPurger {
208 pub fn new() -> Self {
210 Self {
211 keep_classes: HashSet::new(),
212 remove_classes: HashSet::new(),
213 keep_rules: HashSet::new(),
214 remove_rules: HashSet::new(),
215 }
216 }
217
218 pub fn keep_classes(&mut self, classes: HashSet<String>) {
220 self.keep_classes.extend(classes);
221 }
222
223 pub fn remove_classes(&mut self, classes: HashSet<String>) {
225 self.remove_classes.extend(classes);
226 }
227
228 pub fn purge_css(&self, css: &str) -> String {
230 let mut result = String::new();
231 let lines: Vec<&str> = css.lines().collect();
232
233 let mut in_rule = false;
234 let mut current_rule = String::new();
235 let mut rule_selectors = Vec::new();
236
237 for line in lines {
238 let trimmed = line.trim();
239
240 if trimmed.ends_with('{') {
241 in_rule = true;
243 current_rule = line.to_string();
244 rule_selectors = self.extract_selectors(trimmed);
245 } else if trimmed == "}" && in_rule {
246 current_rule.push_str(&format!("{}\n", line));
248
249 if self.should_keep_rule(&rule_selectors) {
250 result.push_str(¤t_rule);
251 }
252
253 in_rule = false;
254 current_rule.clear();
255 rule_selectors.clear();
256 } else if in_rule {
257 current_rule.push_str(&format!("{}\n", line));
259 } else {
260 result.push_str(&format!("{}\n", line));
262 }
263 }
264
265 result
266 }
267
268 fn extract_selectors(&self, line: &str) -> Vec<String> {
270 let selector_part = line.trim_end_matches(" {");
271 selector_part
272 .split(',')
273 .map(|s| s.trim().to_string())
274 .collect()
275 }
276
277 fn should_keep_rule(&self, selectors: &[String]) -> bool {
279 for selector in selectors {
280 if self.should_keep_selector(selector) {
281 return true;
282 }
283 }
284 false
285 }
286
287 fn should_keep_selector(&self, selector: &str) -> bool {
289 if selector.starts_with('*') || selector.starts_with("html") || selector.starts_with("body") {
291 return true;
292 }
293
294 for class in &self.keep_classes {
296 if selector.contains(&format!(".{}", class)) {
297 return true;
298 }
299 }
300
301 for class in &self.remove_classes {
303 if selector.contains(&format!(".{}", class)) {
304 return false;
305 }
306 }
307
308 !selector.contains('.')
310 }
311
312 pub fn calculate_optimization(&self, original_css: &str, optimized_css: &str) -> OptimizationResult {
314 let original_size = original_css.len();
315 let optimized_size = optimized_css.len();
316 let reduction_percentage = if original_size > 0 {
317 ((original_size - optimized_size) as f32 / original_size as f32) * 100.0
318 } else {
319 0.0
320 };
321
322 let classes_removed = self.remove_classes.len();
323 let rules_removed = self.remove_rules.len();
324
325 let mut warnings = Vec::new();
326 if reduction_percentage > 50.0 {
327 warnings.push("Large size reduction detected. Verify all functionality still works.".to_string());
328 }
329 if classes_removed > 100 {
330 warnings.push("Many classes removed. Check for missing styles.".to_string());
331 }
332
333 OptimizationResult {
334 original_size,
335 optimized_size,
336 reduction_percentage,
337 classes_removed,
338 rules_removed,
339 warnings,
340 }
341 }
342}
343
344impl BundleAnalyzer {
345 pub fn new() -> Self {
347 Self {
348 class_stats: HashMap::new(),
349 rule_stats: HashMap::new(),
350 metrics: PerformanceMetrics {
351 total_size: 0,
352 class_count: 0,
353 rule_count: 0,
354 avg_class_size: 0.0,
355 avg_rule_size: 0.0,
356 compression_ratio: 0.0,
357 },
358 }
359 }
360
361 pub fn analyze_bundle(&mut self, css: &str) {
363 self.analyze_classes(css);
364 self.analyze_rules(css);
365 self.calculate_metrics(css);
366 }
367
368 fn analyze_classes(&mut self, css: &str) {
370 let lines: Vec<&str> = css.lines().collect();
371
372 for line in lines {
373 if line.contains('.') && line.contains('{') {
374 let selectors = self.extract_selectors(line);
375 for selector in selectors {
376 if let Some(class_name) = self.extract_class_name(&selector) {
377 let stats = self.class_stats.entry(class_name.clone()).or_insert_with(|| {
378 ClassUsageStats {
379 usage_count: 0,
380 used_in_files: HashSet::new(),
381 is_critical: false,
382 dependencies: HashSet::new(),
383 }
384 });
385 stats.usage_count += 1;
386 }
387 }
388 }
389 }
390 }
391
392 fn analyze_rules(&mut self, css: &str) {
394 let lines: Vec<&str> = css.lines().collect();
395 let mut current_rule = String::new();
396 let mut in_rule = false;
397
398 for line in lines {
399 let trimmed = line.trim();
400
401 if trimmed.ends_with('{') {
402 in_rule = true;
403 current_rule = line.to_string();
404 } else if trimmed == "}" && in_rule {
405 current_rule.push_str(&format!("{}\n", line));
406
407 let rule_id = format!("rule_{}", self.rule_stats.len());
408 let selectors = self.extract_selectors(¤t_rule);
409 let properties = self.extract_properties(¤t_rule);
410
411 self.rule_stats.insert(rule_id, RuleUsageStats {
412 usage_count: 1,
413 selectors: selectors.into_iter().collect(),
414 properties: properties.into_iter().collect(),
415 size_bytes: current_rule.len(),
416 });
417
418 in_rule = false;
419 current_rule.clear();
420 } else if in_rule {
421 current_rule.push_str(&format!("{}\n", line));
422 }
423 }
424 }
425
426 fn calculate_metrics(&mut self, css: &str) {
428 self.metrics.total_size = css.len();
429 self.metrics.class_count = self.class_stats.len();
430 self.metrics.rule_count = self.rule_stats.len();
431
432 if self.metrics.class_count > 0 {
433 let total_class_size: usize = self.class_stats.values()
434 .map(|stats| stats.usage_count as usize * 10) .sum();
436 self.metrics.avg_class_size = total_class_size as f32 / self.metrics.class_count as f32;
437 }
438
439 if self.metrics.rule_count > 0 {
440 let total_rule_size: usize = self.rule_stats.values()
441 .map(|stats| stats.size_bytes)
442 .sum();
443 self.metrics.avg_rule_size = total_rule_size as f32 / self.metrics.rule_count as f32;
444 }
445
446 self.metrics.compression_ratio = if self.metrics.total_size > 0 {
448 (self.metrics.total_size as f32 - self.metrics.avg_rule_size) / self.metrics.total_size as f32
449 } else {
450 0.0
451 };
452 }
453
454 fn extract_selectors(&self, line: &str) -> Vec<String> {
456 let selector_part = line.trim_end_matches(" {");
457 selector_part
458 .split(',')
459 .map(|s| s.trim().to_string())
460 .collect()
461 }
462
463 fn extract_class_name(&self, selector: &str) -> Option<String> {
465 if let Some(start) = selector.find('.') {
466 let class_part = &selector[start + 1..];
467 if let Some(end) = class_part.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
468 Some(class_part[..end].to_string())
469 } else {
470 Some(class_part.to_string())
471 }
472 } else {
473 None
474 }
475 }
476
477 fn extract_properties(&self, rule: &str) -> Vec<String> {
479 let mut properties = Vec::new();
480 let lines: Vec<&str> = rule.lines().collect();
481
482 for line in lines {
483 let trimmed = line.trim();
484 if trimmed.contains(':') && !trimmed.ends_with('{') && !trimmed.ends_with('}') {
485 if let Some(colon_pos) = trimmed.find(':') {
486 let property = trimmed[..colon_pos].trim().to_string();
487 properties.push(property);
488 }
489 }
490 }
491
492 properties
493 }
494
495 pub fn get_recommendations(&self) -> Vec<String> {
497 let mut recommendations = Vec::new();
498
499 if self.metrics.total_size > 100_000 {
500 recommendations.push("Bundle size is large. Consider code splitting.".to_string());
501 }
502
503 if self.metrics.class_count > 1000 {
504 recommendations.push("Many classes detected. Consider purging unused classes.".to_string());
505 }
506
507 if self.metrics.avg_rule_size > 200.0 {
508 recommendations.push("Large CSS rules detected. Consider breaking them down.".to_string());
509 }
510
511 if self.metrics.compression_ratio < 0.3 {
512 recommendations.push("Low compression ratio. Consider optimization.".to_string());
513 }
514
515 let unused_classes: Vec<_> = self.class_stats
516 .iter()
517 .filter(|(_, stats)| stats.usage_count == 1)
518 .map(|(name, _)| name)
519 .collect();
520
521 if unused_classes.len() > 50 {
522 recommendations.push(format!(
523 "{} classes used only once. Consider consolidation.",
524 unused_classes.len()
525 ));
526 }
527
528 recommendations
529 }
530}
531
532impl fmt::Display for OptimizationResult {
533 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534 write!(
535 f,
536 "Optimization Result: {} bytes -> {} bytes ({}% reduction)",
537 self.original_size,
538 self.optimized_size,
539 self.reduction_percentage
540 )
541 }
542}
543
544impl fmt::Display for PerformanceMetrics {
545 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546 write!(
547 f,
548 "Bundle: {} bytes, {} classes, {} rules, {:.1}% compression",
549 self.total_size,
550 self.class_count,
551 self.rule_count,
552 self.compression_ratio * 100.0
553 )
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_class_analyzer_creation() {
563 let analyzer = ClassAnalyzer::new();
564 assert!(analyzer.used_classes.is_empty());
565 assert!(analyzer.unused_classes.is_empty());
566 assert!(analyzer.dependencies.is_empty());
567 assert!(analyzer.critical_classes.is_empty());
568 }
569
570 #[test]
571 fn test_class_analyzer_add_used_class() {
572 let mut analyzer = ClassAnalyzer::new();
573 analyzer.add_used_class("bg-red-500".to_string());
574
575 assert!(analyzer.used_classes.contains("bg-red-500"));
576 assert_eq!(analyzer.used_classes.len(), 1);
577 }
578
579 #[test]
580 fn test_class_analyzer_add_multiple_classes() {
581 let mut analyzer = ClassAnalyzer::new();
582 analyzer.add_used_classes(vec!["bg-red-500".to_string(), "text-white".to_string()]);
583
584 assert!(analyzer.used_classes.contains("bg-red-500"));
585 assert!(analyzer.used_classes.contains("text-white"));
586 assert_eq!(analyzer.used_classes.len(), 2);
587 }
588
589 #[test]
590 fn test_class_analyzer_add_dependency() {
591 let mut analyzer = ClassAnalyzer::new();
592 analyzer.add_dependency("btn".to_string(), "bg-blue-500".to_string());
593
594 assert!(analyzer.dependencies.contains_key("btn"));
595 assert!(analyzer.dependencies["btn"].contains("bg-blue-500"));
596 }
597
598 #[test]
599 fn test_class_analyzer_mark_critical() {
600 let mut analyzer = ClassAnalyzer::new();
601 analyzer.mark_critical("container".to_string());
602
603 assert!(analyzer.critical_classes.contains("container"));
604 }
605
606 #[test]
607 fn test_class_analyzer_analyze_usage() {
608 let mut analyzer = ClassAnalyzer::new();
609 analyzer.add_used_class("bg-red-500".to_string());
610 analyzer.mark_critical("container".to_string());
611
612 let all_classes: HashSet<String> = vec![
613 "bg-red-500".to_string(),
614 "bg-blue-500".to_string(),
615 "container".to_string(),
616 "unused-class".to_string(),
617 ].into_iter().collect();
618
619 analyzer.analyze_usage(all_classes);
620
621 assert!(analyzer.used_classes.contains("bg-red-500"));
622 assert!(analyzer.unused_classes.contains("bg-blue-500"));
623 assert!(analyzer.unused_classes.contains("unused-class"));
624 assert!(!analyzer.unused_classes.contains("container")); }
626
627 #[test]
628 fn test_css_purger_creation() {
629 let purger = CssPurger::new();
630 assert!(purger.keep_classes.is_empty());
631 assert!(purger.remove_classes.is_empty());
632 assert!(purger.keep_rules.is_empty());
633 assert!(purger.remove_rules.is_empty());
634 }
635
636 #[test]
637 fn test_css_purger_keep_classes() {
638 let mut purger = CssPurger::new();
639 let classes: HashSet<String> = vec!["bg-red-500".to_string(), "text-white".to_string()].into_iter().collect();
640 purger.keep_classes(classes);
641
642 assert!(purger.keep_classes.contains("bg-red-500"));
643 assert!(purger.keep_classes.contains("text-white"));
644 }
645
646 #[test]
647 fn test_css_purger_remove_classes() {
648 let mut purger = CssPurger::new();
649 let classes: HashSet<String> = vec!["unused-class".to_string()].into_iter().collect();
650 purger.remove_classes(classes);
651
652 assert!(purger.remove_classes.contains("unused-class"));
653 }
654
655 #[test]
656 fn test_css_purger_purge_css() {
657 let mut purger = CssPurger::new();
658 purger.keep_classes(vec!["bg-red-500".to_string()].into_iter().collect());
659 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
660
661 let css = r#"
662.bg-red-500 { background-color: #ef4444; }
663.unused-class { display: none; }
664.text-white { color: white; }
665"#;
666
667 let result = purger.purge_css(css);
668
669 assert!(!result.is_empty());
671 assert!(result.contains(".bg-red-500"));
673 assert!(result.contains("{"));
675 assert!(result.contains("}"));
676 }
677
678 #[test]
679 fn test_css_purger_calculate_optimization() {
680 let mut purger = CssPurger::new();
681 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
682
683 let original_css = ".bg-red-500 { color: red; } .unused-class { display: none; }";
684 let optimized_css = ".bg-red-500 { color: red; }";
685
686 let result = purger.calculate_optimization(original_css, optimized_css);
687
688 assert!(result.original_size > result.optimized_size);
689 assert!(result.reduction_percentage > 0.0);
690 assert_eq!(result.classes_removed, 1);
691 }
692
693 #[test]
694 fn test_bundle_analyzer_creation() {
695 let analyzer = BundleAnalyzer::new();
696 assert!(analyzer.class_stats.is_empty());
697 assert!(analyzer.rule_stats.is_empty());
698 assert_eq!(analyzer.metrics.total_size, 0);
699 }
700
701 #[test]
702 fn test_bundle_analyzer_analyze_bundle() {
703 let mut analyzer = BundleAnalyzer::new();
704 let css = r#"
705.bg-red-500 { background-color: #ef4444; }
706.text-white { color: white; }
707"#;
708
709 analyzer.analyze_bundle(css);
710
711 assert!(analyzer.class_stats.contains_key("bg-red-500"));
713 assert!(analyzer.class_stats.contains_key("text-white"));
714 assert!(analyzer.metrics.class_count >= 2);
716 assert!(analyzer.metrics.rule_count >= 0);
718 }
719
720 #[test]
721 fn test_optimization_result_display() {
722 let result = OptimizationResult {
723 original_size: 1000,
724 optimized_size: 500,
725 reduction_percentage: 50.0,
726 classes_removed: 10,
727 rules_removed: 5,
728 warnings: vec!["Test warning".to_string()],
729 };
730
731 let display = format!("{}", result);
732 assert!(display.contains("1000 bytes -> 500 bytes"));
733 assert!(display.contains("50% reduction"));
734 }
735
736 #[test]
737 fn test_performance_metrics_display() {
738 let metrics = PerformanceMetrics {
739 total_size: 10000,
740 class_count: 100,
741 rule_count: 50,
742 avg_class_size: 10.0,
743 avg_rule_size: 20.0,
744 compression_ratio: 0.3,
745 };
746
747 let display = format!("{}", metrics);
748 assert!(display.contains("10000 bytes"));
749 assert!(display.contains("100 classes"));
750 assert!(display.contains("50 rules"));
751 assert!(display.contains("30.0% compression"));
752 }
753
754 #[test]
755 fn test_class_analyzer_serialization() {
756 let mut analyzer = ClassAnalyzer::new();
757 analyzer.add_used_class("bg-red-500".to_string());
758 analyzer.mark_critical("container".to_string());
759
760 let serialized = serde_json::to_string(&analyzer).unwrap();
761 let deserialized: ClassAnalyzer = serde_json::from_str(&serialized).unwrap();
762 assert_eq!(analyzer, deserialized);
763 }
764
765 #[test]
766 fn test_css_purger_serialization() {
767 let mut purger = CssPurger::new();
768 purger.keep_classes(vec!["bg-red-500".to_string()].into_iter().collect());
769 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
770
771 let serialized = serde_json::to_string(&purger).unwrap();
772 let deserialized: CssPurger = serde_json::from_str(&serialized).unwrap();
773 assert_eq!(purger, deserialized);
774 }
775
776 #[test]
777 fn test_bundle_analyzer_serialization() {
778 let mut analyzer = BundleAnalyzer::new();
779 analyzer.analyze_bundle(".test { color: red; }");
780
781 let serialized = serde_json::to_string(&analyzer).unwrap();
782 let deserialized: BundleAnalyzer = serde_json::from_str(&serialized).unwrap();
783 assert_eq!(analyzer, deserialized);
784 }
785}