1use crate::grade::{GateCheckResult, GateViolation, QualityGates, ViolationSeverity};
6use std::path::Path;
7
8#[derive(Debug, Clone, Default, PartialEq)]
14pub struct BuildInfo {
15 pub wasm_path: String,
17 pub size_bytes: u64,
19 pub compressed_size_bytes: u64,
21 pub mode: BuildMode,
23 pub target: String,
25 pub timestamp: u64,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum BuildMode {
32 #[default]
33 Debug,
34 Release,
35}
36
37impl std::fmt::Display for BuildMode {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::Debug => write!(f, "debug"),
41 Self::Release => write!(f, "release"),
42 }
43 }
44}
45
46impl BuildInfo {
47 #[must_use]
49 pub fn size_kb(&self) -> u32 {
50 ((self.size_bytes + 1023) / 1024) as u32
51 }
52
53 #[must_use]
55 pub fn compressed_size_kb(&self) -> u32 {
56 ((self.compressed_size_bytes + 1023) / 1024) as u32
57 }
58
59 #[must_use]
61 pub fn compression_ratio(&self) -> f64 {
62 if self.size_bytes == 0 {
63 return 0.0;
64 }
65 self.compressed_size_bytes as f64 / self.size_bytes as f64
66 }
67
68 #[must_use]
70 pub fn meets_size_limit(&self, limit_kb: u32) -> bool {
71 self.size_kb() <= limit_kb
72 }
73}
74
75#[derive(Debug, Default)]
81pub struct BundleAnalyzer {
82 pub max_size_kb: u32,
84 pub expected_compression_ratio: f64,
86 pub forbidden_patterns: Vec<String>,
88}
89
90impl BundleAnalyzer {
91 #[must_use]
93 pub fn new() -> Self {
94 Self {
95 max_size_kb: 500, expected_compression_ratio: 0.3, forbidden_patterns: vec![
98 "PANIC_MESSAGE".to_string(), "debug_assert".to_string(), ],
101 }
102 }
103
104 pub fn analyze(&self, path: &Path) -> Result<BundleAnalysis, BundleError> {
110 let data = std::fs::read(path).map_err(|e| BundleError::IoError(e.to_string()))?;
112
113 let size_bytes = data.len() as u64;
114
115 let compressed_size = self.estimate_compressed_size(&data);
117
118 let is_valid_wasm = data.len() >= 4 && &data[0..4] == b"\0asm";
120
121 let forbidden_found = self.find_forbidden_patterns(&data);
123
124 let sections = if is_valid_wasm {
126 self.parse_wasm_sections(&data)
127 } else {
128 Vec::new()
129 };
130
131 Ok(BundleAnalysis {
132 info: BuildInfo {
133 wasm_path: path.display().to_string(),
134 size_bytes,
135 compressed_size_bytes: compressed_size,
136 mode: if size_bytes > 1_000_000 {
137 BuildMode::Debug
138 } else {
139 BuildMode::Release
140 },
141 target: "wasm32-unknown-unknown".to_string(),
142 timestamp: 0,
143 },
144 is_valid_wasm,
145 sections,
146 forbidden_found,
147 })
148 }
149
150 #[must_use]
152 pub fn analyze_bytes(&self, data: &[u8]) -> BundleAnalysis {
153 let size_bytes = data.len() as u64;
154 let compressed_size = self.estimate_compressed_size(data);
155 let is_valid_wasm = data.len() >= 4 && &data[0..4] == b"\0asm";
156 let forbidden_found = self.find_forbidden_patterns(data);
157 let sections = if is_valid_wasm {
158 self.parse_wasm_sections(data)
159 } else {
160 Vec::new()
161 };
162
163 BundleAnalysis {
164 info: BuildInfo {
165 wasm_path: "<memory>".to_string(),
166 size_bytes,
167 compressed_size_bytes: compressed_size,
168 mode: if size_bytes > 1_000_000 {
169 BuildMode::Debug
170 } else {
171 BuildMode::Release
172 },
173 target: "wasm32-unknown-unknown".to_string(),
174 timestamp: 0,
175 },
176 is_valid_wasm,
177 sections,
178 forbidden_found,
179 }
180 }
181
182 fn estimate_compressed_size(&self, data: &[u8]) -> u64 {
184 let ratio = self.expected_compression_ratio;
186 (data.len() as f64 * ratio) as u64
187 }
188
189 fn find_forbidden_patterns(&self, data: &[u8]) -> Vec<String> {
191 let mut found = Vec::new();
192 let data_str = String::from_utf8_lossy(data);
193
194 for pattern in &self.forbidden_patterns {
195 if data_str.contains(pattern) {
196 found.push(pattern.clone());
197 }
198 }
199
200 found
201 }
202
203 fn parse_wasm_sections(&self, data: &[u8]) -> Vec<WasmSection> {
205 let mut sections = Vec::new();
206
207 if data.len() < 8 {
208 return sections;
209 }
210
211 let mut pos = 8;
213
214 while pos < data.len() {
215 if pos >= data.len() {
216 break;
217 }
218
219 let section_id = data[pos];
220 pos += 1;
221
222 let (size, bytes_read) = Self::read_leb128(&data[pos..]);
224 pos += bytes_read;
225
226 if pos + size as usize > data.len() {
227 break;
228 }
229
230 sections.push(WasmSection {
231 id: section_id,
232 name: Self::section_name(section_id),
233 size,
234 });
235
236 pos += size as usize;
237 }
238
239 sections
240 }
241
242 fn read_leb128(data: &[u8]) -> (u64, usize) {
244 let mut result = 0u64;
245 let mut shift = 0;
246 let mut bytes_read = 0;
247
248 for &byte in data {
249 result |= u64::from(byte & 0x7F) << shift;
250 bytes_read += 1;
251 if byte & 0x80 == 0 {
252 break;
253 }
254 shift += 7;
255 if shift >= 64 {
256 break;
257 }
258 }
259
260 (result, bytes_read)
261 }
262
263 fn section_name(id: u8) -> String {
265 match id {
266 0 => "custom".to_string(),
267 1 => "type".to_string(),
268 2 => "import".to_string(),
269 3 => "function".to_string(),
270 4 => "table".to_string(),
271 5 => "memory".to_string(),
272 6 => "global".to_string(),
273 7 => "export".to_string(),
274 8 => "start".to_string(),
275 9 => "element".to_string(),
276 10 => "code".to_string(),
277 11 => "data".to_string(),
278 12 => "data_count".to_string(),
279 _ => format!("unknown_{id}"),
280 }
281 }
282
283 #[must_use]
285 pub fn check_gates(&self, analysis: &BundleAnalysis, gates: &QualityGates) -> GateCheckResult {
286 let mut violations = Vec::new();
287
288 if analysis.info.size_kb() > gates.performance.max_bundle_size_kb {
290 violations.push(GateViolation {
291 gate: "max_bundle_size_kb".to_string(),
292 expected: format!("<= {}KB", gates.performance.max_bundle_size_kb),
293 actual: format!("{}KB", analysis.info.size_kb()),
294 severity: ViolationSeverity::Error,
295 });
296 }
297
298 if !analysis.forbidden_found.is_empty() {
300 violations.push(GateViolation {
301 gate: "forbidden_patterns".to_string(),
302 expected: "no debug symbols".to_string(),
303 actual: format!("found: {}", analysis.forbidden_found.join(", ")),
304 severity: ViolationSeverity::Warning,
305 });
306 }
307
308 if !analysis.is_valid_wasm {
310 violations.push(GateViolation {
311 gate: "wasm_validity".to_string(),
312 expected: "valid WASM file".to_string(),
313 actual: "invalid WASM magic bytes".to_string(),
314 severity: ViolationSeverity::Error,
315 });
316 }
317
318 if analysis.info.mode == BuildMode::Debug {
320 violations.push(GateViolation {
321 gate: "build_mode".to_string(),
322 expected: "release build".to_string(),
323 actual: "debug build (size > 1MB)".to_string(),
324 severity: ViolationSeverity::Warning,
325 });
326 }
327
328 let passed = !violations
329 .iter()
330 .any(|v| v.severity == ViolationSeverity::Error);
331
332 GateCheckResult { passed, violations }
333 }
334}
335
336#[derive(Debug, Clone, Default)]
338pub struct BundleAnalysis {
339 pub info: BuildInfo,
341 pub is_valid_wasm: bool,
343 pub sections: Vec<WasmSection>,
345 pub forbidden_found: Vec<String>,
347}
348
349impl BundleAnalysis {
350 #[must_use]
352 pub fn code_size(&self) -> u64 {
353 self.sections
354 .iter()
355 .find(|s| s.name == "code")
356 .map_or(0, |s| s.size)
357 }
358
359 #[must_use]
361 pub fn data_size(&self) -> u64 {
362 self.sections
363 .iter()
364 .find(|s| s.name == "data")
365 .map_or(0, |s| s.size)
366 }
367
368 #[must_use]
370 pub fn custom_size(&self) -> u64 {
371 self.sections
372 .iter()
373 .filter(|s| s.name == "custom")
374 .map(|s| s.size)
375 .sum()
376 }
377}
378
379#[derive(Debug, Clone, Default)]
381pub struct WasmSection {
382 pub id: u8,
384 pub name: String,
386 pub size: u64,
388}
389
390#[derive(Debug)]
392pub enum BundleError {
393 IoError(String),
395 InvalidFormat(String),
397}
398
399impl std::fmt::Display for BundleError {
400 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401 match self {
402 Self::IoError(msg) => write!(f, "IO error: {msg}"),
403 Self::InvalidFormat(msg) => write!(f, "invalid format: {msg}"),
404 }
405 }
406}
407
408impl std::error::Error for BundleError {}
409
410#[derive(Debug, Clone, Default)]
416pub struct SizeTracker {
417 pub records: Vec<SizeRecord>,
419 pub baseline_kb: Option<u32>,
421}
422
423#[derive(Debug, Clone)]
425pub struct SizeRecord {
426 pub timestamp: u64,
428 pub size_kb: u32,
430 pub commit: Option<String>,
432 pub label: String,
434}
435
436impl SizeTracker {
437 #[must_use]
439 pub fn new() -> Self {
440 Self::default()
441 }
442
443 pub fn record(&mut self, size_kb: u32, label: &str, commit: Option<&str>) {
445 self.records.push(SizeRecord {
446 timestamp: std::time::SystemTime::now()
447 .duration_since(std::time::UNIX_EPOCH)
448 .map_or(0, |d| d.as_secs()),
449 size_kb,
450 commit: commit.map(String::from),
451 label: label.to_string(),
452 });
453 }
454
455 pub fn set_baseline(&mut self, size_kb: u32) {
457 self.baseline_kb = Some(size_kb);
458 }
459
460 #[must_use]
462 pub fn change_from_baseline(&self, current_kb: u32) -> Option<i32> {
463 self.baseline_kb.map(|b| current_kb as i32 - b as i32)
464 }
465
466 #[must_use]
468 pub fn change_percentage(&self, current_kb: u32) -> Option<f64> {
469 self.baseline_kb.map(|b| {
470 if b == 0 {
471 0.0
472 } else {
473 (f64::from(current_kb) - f64::from(b)) / f64::from(b) * 100.0
474 }
475 })
476 }
477
478 #[must_use]
480 pub fn min_size(&self) -> Option<u32> {
481 self.records.iter().map(|r| r.size_kb).min()
482 }
483
484 #[must_use]
486 pub fn max_size(&self) -> Option<u32> {
487 self.records.iter().map(|r| r.size_kb).max()
488 }
489
490 #[must_use]
492 pub fn latest(&self) -> Option<&SizeRecord> {
493 self.records.last()
494 }
495}
496
497#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
510 fn test_build_info_default() {
511 let info = BuildInfo::default();
512 assert_eq!(info.size_bytes, 0);
513 assert_eq!(info.compressed_size_bytes, 0);
514 assert_eq!(info.mode, BuildMode::Debug);
515 }
516
517 #[test]
518 fn test_build_info_size_kb() {
519 let info = BuildInfo {
520 size_bytes: 1024,
521 ..Default::default()
522 };
523 assert_eq!(info.size_kb(), 1);
524
525 let info = BuildInfo {
526 size_bytes: 1025,
527 ..Default::default()
528 };
529 assert_eq!(info.size_kb(), 2); }
531
532 #[test]
533 fn test_build_info_compression_ratio() {
534 let info = BuildInfo {
535 size_bytes: 1000,
536 compressed_size_bytes: 300,
537 ..Default::default()
538 };
539 assert!((info.compression_ratio() - 0.3).abs() < 0.01);
540 }
541
542 #[test]
543 fn test_build_info_compression_ratio_zero() {
544 let info = BuildInfo {
545 size_bytes: 0,
546 compressed_size_bytes: 0,
547 ..Default::default()
548 };
549 assert_eq!(info.compression_ratio(), 0.0);
550 }
551
552 #[test]
553 fn test_build_info_meets_size_limit() {
554 let info = BuildInfo {
555 size_bytes: 400 * 1024, ..Default::default()
557 };
558 assert!(info.meets_size_limit(500));
559 assert!(!info.meets_size_limit(300));
560 }
561
562 #[test]
563 fn test_build_mode_display() {
564 assert_eq!(BuildMode::Debug.to_string(), "debug");
565 assert_eq!(BuildMode::Release.to_string(), "release");
566 }
567
568 #[test]
573 fn test_bundle_analyzer_new() {
574 let analyzer = BundleAnalyzer::new();
575 assert_eq!(analyzer.max_size_kb, 500);
576 assert!((analyzer.expected_compression_ratio - 0.3).abs() < 0.01);
577 }
578
579 #[test]
580 fn test_bundle_analyzer_analyze_bytes_valid_wasm() {
581 let mut data = vec![0x00, 0x61, 0x73, 0x6D]; data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); let analyzer = BundleAnalyzer::new();
586 let analysis = analyzer.analyze_bytes(&data);
587
588 assert!(analysis.is_valid_wasm);
589 assert_eq!(analysis.info.size_bytes, 8);
590 }
591
592 #[test]
593 fn test_bundle_analyzer_analyze_bytes_invalid_wasm() {
594 let data = b"not wasm data";
595 let analyzer = BundleAnalyzer::new();
596 let analysis = analyzer.analyze_bytes(data);
597
598 assert!(!analysis.is_valid_wasm);
599 }
600
601 #[test]
602 fn test_bundle_analyzer_forbidden_patterns() {
603 let mut analyzer = BundleAnalyzer::new();
604 analyzer.forbidden_patterns = vec!["SECRET".to_string()];
605
606 let data = b"some data with SECRET inside";
607 let analysis = analyzer.analyze_bytes(data);
608
609 assert!(analysis.forbidden_found.contains(&"SECRET".to_string()));
610 }
611
612 #[test]
613 fn test_bundle_analyzer_compression_estimate() {
614 let analyzer = BundleAnalyzer::new();
615 let data = vec![0u8; 10000]; let analysis = analyzer.analyze_bytes(&data);
617
618 assert!(analysis.info.compressed_size_bytes < analysis.info.size_bytes);
620 let ratio = analysis.info.compression_ratio();
621 assert!((ratio - 0.3).abs() < 0.1);
622 }
623
624 #[test]
625 fn test_bundle_analyzer_check_gates_passes() {
626 let analyzer = BundleAnalyzer::new();
627 let gates = QualityGates::default();
628
629 let mut data = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
631 data.extend_from_slice(&vec![0u8; 1000]); let analysis = analyzer.analyze_bytes(&data);
634 let result = analyzer.check_gates(&analysis, &gates);
635
636 assert!(result.passed);
637 }
638
639 #[test]
640 fn test_bundle_analyzer_check_gates_size_fails() {
641 let analyzer = BundleAnalyzer::new();
642 let mut gates = QualityGates::default();
643 gates.performance.max_bundle_size_kb = 1; let mut data = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
646 data.extend_from_slice(&vec![0u8; 5000]); let analysis = analyzer.analyze_bytes(&data);
649 let result = analyzer.check_gates(&analysis, &gates);
650
651 assert!(!result.passed);
652 assert!(result
653 .violations
654 .iter()
655 .any(|v| v.gate == "max_bundle_size_kb"));
656 }
657
658 #[test]
659 fn test_bundle_analyzer_check_gates_invalid_wasm() {
660 let analyzer = BundleAnalyzer::new();
661 let gates = QualityGates::default();
662
663 let data = b"not wasm at all";
664 let analysis = analyzer.analyze_bytes(data);
665 let result = analyzer.check_gates(&analysis, &gates);
666
667 assert!(!result.passed);
668 assert!(result.violations.iter().any(|v| v.gate == "wasm_validity"));
669 }
670
671 #[test]
672 fn test_bundle_analyzer_leb128() {
673 let data = [0x7F];
675 let (val, bytes) = BundleAnalyzer::read_leb128(&data);
676 assert_eq!(val, 127);
677 assert_eq!(bytes, 1);
678
679 let data = [0x80, 0x01];
681 let (val, bytes) = BundleAnalyzer::read_leb128(&data);
682 assert_eq!(val, 128);
683 assert_eq!(bytes, 2);
684 }
685
686 #[test]
687 fn test_bundle_analyzer_section_names() {
688 assert_eq!(BundleAnalyzer::section_name(0), "custom");
689 assert_eq!(BundleAnalyzer::section_name(1), "type");
690 assert_eq!(BundleAnalyzer::section_name(10), "code");
691 assert_eq!(BundleAnalyzer::section_name(11), "data");
692 assert_eq!(BundleAnalyzer::section_name(255), "unknown_255");
693 }
694
695 #[test]
700 fn test_bundle_analysis_code_size() {
701 let analysis = BundleAnalysis {
702 sections: vec![
703 WasmSection {
704 id: 10,
705 name: "code".to_string(),
706 size: 5000,
707 },
708 WasmSection {
709 id: 11,
710 name: "data".to_string(),
711 size: 1000,
712 },
713 ],
714 ..Default::default()
715 };
716
717 assert_eq!(analysis.code_size(), 5000);
718 assert_eq!(analysis.data_size(), 1000);
719 }
720
721 #[test]
722 fn test_bundle_analysis_custom_size() {
723 let analysis = BundleAnalysis {
724 sections: vec![
725 WasmSection {
726 id: 0,
727 name: "custom".to_string(),
728 size: 100,
729 },
730 WasmSection {
731 id: 0,
732 name: "custom".to_string(),
733 size: 200,
734 },
735 ],
736 ..Default::default()
737 };
738
739 assert_eq!(analysis.custom_size(), 300);
740 }
741
742 #[test]
743 fn test_bundle_analysis_empty_sections() {
744 let analysis = BundleAnalysis::default();
745 assert_eq!(analysis.code_size(), 0);
746 assert_eq!(analysis.data_size(), 0);
747 assert_eq!(analysis.custom_size(), 0);
748 }
749
750 #[test]
755 fn test_bundle_error_display() {
756 let io_err = BundleError::IoError("file not found".to_string());
757 assert!(io_err.to_string().contains("IO error"));
758
759 let format_err = BundleError::InvalidFormat("bad magic".to_string());
760 assert!(format_err.to_string().contains("invalid format"));
761 }
762
763 #[test]
768 fn test_size_tracker_new() {
769 let tracker = SizeTracker::new();
770 assert!(tracker.records.is_empty());
771 assert!(tracker.baseline_kb.is_none());
772 }
773
774 #[test]
775 fn test_size_tracker_record() {
776 let mut tracker = SizeTracker::new();
777 tracker.record(100, "initial", None);
778 tracker.record(110, "feature-a", Some("abc123"));
779
780 assert_eq!(tracker.records.len(), 2);
781 assert_eq!(tracker.records[0].size_kb, 100);
782 assert_eq!(tracker.records[1].size_kb, 110);
783 assert_eq!(tracker.records[1].commit, Some("abc123".to_string()));
784 }
785
786 #[test]
787 fn test_size_tracker_baseline() {
788 let mut tracker = SizeTracker::new();
789 tracker.set_baseline(100);
790
791 assert_eq!(tracker.change_from_baseline(110), Some(10));
792 assert_eq!(tracker.change_from_baseline(90), Some(-10));
793 }
794
795 #[test]
796 fn test_size_tracker_change_percentage() {
797 let mut tracker = SizeTracker::new();
798 tracker.set_baseline(100);
799
800 let pct = tracker.change_percentage(110).unwrap();
801 assert!((pct - 10.0).abs() < 0.01);
802
803 let pct = tracker.change_percentage(50).unwrap();
804 assert!((pct - (-50.0)).abs() < 0.01);
805 }
806
807 #[test]
808 fn test_size_tracker_min_max() {
809 let mut tracker = SizeTracker::new();
810 tracker.record(100, "a", None);
811 tracker.record(150, "b", None);
812 tracker.record(120, "c", None);
813
814 assert_eq!(tracker.min_size(), Some(100));
815 assert_eq!(tracker.max_size(), Some(150));
816 }
817
818 #[test]
819 fn test_size_tracker_latest() {
820 let mut tracker = SizeTracker::new();
821 tracker.record(100, "first", None);
822 tracker.record(200, "second", None);
823
824 let latest = tracker.latest().unwrap();
825 assert_eq!(latest.size_kb, 200);
826 assert_eq!(latest.label, "second");
827 }
828
829 #[test]
830 fn test_size_tracker_empty_min_max() {
831 let tracker = SizeTracker::new();
832 assert!(tracker.min_size().is_none());
833 assert!(tracker.max_size().is_none());
834 assert!(tracker.latest().is_none());
835 }
836
837 #[test]
838 fn test_size_tracker_baseline_zero() {
839 let mut tracker = SizeTracker::new();
840 tracker.set_baseline(0);
841
842 assert_eq!(tracker.change_percentage(100), Some(0.0));
843 }
844}