1use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct PhaseProfile {
20 pub phase_number: u8,
21 pub phase_name: String,
22 pub start_time: u64,
23 pub end_time: u64,
24 pub duration_ms: u64,
25 pub requests_sent: u64,
26 pub responses_received: u64,
27 pub findings_discovered: u64,
28 pub error_count: u64,
29 pub response_time_samples_ms: Vec<u64>,
30}
31
32impl PhaseProfile {
33 pub fn response_request_ratio_percent(&self) -> Option<f64> {
34 ratio_percent(self.responses_received, self.requests_sent)
35 }
36
37 pub fn findings_per_100_responses(&self) -> Option<f64> {
38 ratio_percent(self.findings_discovered, self.responses_received)
39 }
40
41 pub fn mean_response_time_ms(&self) -> Option<f64> {
42 mean(&self.response_time_samples_ms)
43 }
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct ResourceMetrics {
49 #[serde(with = "nonnegative_f32")]
50 pub memory_used_mb: f32,
51 #[serde(with = "nonnegative_f32")]
52 pub memory_peak_mb: f32,
53 #[serde(with = "percent_f32")]
54 pub cpu_usage_percent: f32,
55 #[serde(with = "percent_f32")]
56 pub cpu_peak_percent: f32,
57 #[serde(with = "nonnegative_f32")]
58 pub disk_read_mb: f32,
59 #[serde(with = "nonnegative_f32")]
60 pub disk_write_mb: f32,
61 #[serde(with = "nonnegative_f32")]
62 pub network_in_mb: f32,
63 #[serde(with = "nonnegative_f32")]
64 pub network_out_mb: f32,
65}
66
67impl ResourceMetrics {
68 pub fn is_valid(&self) -> bool {
70 [
71 self.memory_used_mb,
72 self.memory_peak_mb,
73 self.disk_read_mb,
74 self.disk_write_mb,
75 self.network_in_mb,
76 self.network_out_mb,
77 ]
78 .into_iter()
79 .all(|value| value.is_finite() && value >= 0.0)
80 && [self.cpu_usage_percent, self.cpu_peak_percent]
81 .into_iter()
82 .all(|value| value.is_finite() && (0.0..=100.0).contains(&value))
83 }
84}
85
86mod nonnegative_f32 {
87 use serde::{Deserialize, Deserializer, Serializer};
88
89 pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
90 where
91 S: Serializer,
92 {
93 if value.is_finite() && *value >= 0.0 {
94 serializer.serialize_f32(*value)
95 } else {
96 Err(serde::ser::Error::custom(
97 "resource measurement must be finite and non-negative",
98 ))
99 }
100 }
101
102 pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
103 where
104 D: Deserializer<'de>,
105 {
106 let value = f32::deserialize(deserializer)?;
107 if value.is_finite() && value >= 0.0 {
108 Ok(value)
109 } else {
110 Err(serde::de::Error::custom(
111 "resource measurement must be finite and non-negative",
112 ))
113 }
114 }
115}
116
117mod percent_f32 {
118 use serde::{Deserialize, Deserializer, Serializer};
119
120 pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
121 where
122 S: Serializer,
123 {
124 if value.is_finite() && (0.0..=100.0).contains(value) {
125 serializer.serialize_f32(*value)
126 } else {
127 Err(serde::ser::Error::custom(
128 "percentage must be finite and between 0 and 100",
129 ))
130 }
131 }
132
133 pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
134 where
135 D: Deserializer<'de>,
136 {
137 let value = f32::deserialize(deserializer)?;
138 if value.is_finite() && (0.0..=100.0).contains(&value) {
139 Ok(value)
140 } else {
141 Err(serde::de::Error::custom(
142 "percentage must be finite and between 0 and 100",
143 ))
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct ScanProfile {
151 pub scan_id: String,
152 pub total_duration_ms: u64,
153 pub phases: Vec<PhaseProfile>,
154 pub resources: ResourceMetrics,
155}
156
157impl ScanProfile {
158 pub fn new(scan_id: String) -> Self {
159 Self {
160 scan_id,
161 total_duration_ms: 0,
162 phases: Vec::new(),
163 resources: ResourceMetrics::default(),
164 }
165 }
166
167 pub fn add_phase(&mut self, phase: PhaseProfile) {
168 self.phases.push(phase);
169 }
170
171 pub fn is_valid(&self) -> bool {
173 self.resources.is_valid()
174 }
175
176 pub fn total_requests(&self) -> u128 {
177 self.phases
178 .iter()
179 .map(|phase| u128::from(phase.requests_sent))
180 .sum()
181 }
182
183 pub fn total_responses(&self) -> u128 {
184 self.phases
185 .iter()
186 .map(|phase| u128::from(phase.responses_received))
187 .sum()
188 }
189
190 pub fn total_findings(&self) -> u128 {
191 self.phases
192 .iter()
193 .map(|phase| u128::from(phase.findings_discovered))
194 .sum()
195 }
196
197 pub fn total_errors(&self) -> u128 {
198 self.phases
199 .iter()
200 .map(|phase| u128::from(phase.error_count))
201 .sum()
202 }
203
204 pub fn response_request_ratio_percent(&self) -> Option<f64> {
205 ratio_percent_u128(self.total_responses(), self.total_requests())
206 }
207
208 pub fn slowest_phases(&self) -> Vec<&PhaseProfile> {
210 let Some(maximum) = self.phases.iter().map(|phase| phase.duration_ms).max() else {
211 return Vec::new();
212 };
213 self.phases
214 .iter()
215 .filter(|phase| phase.duration_ms == maximum)
216 .collect()
217 }
218
219 pub fn most_productive_phases(&self) -> Vec<&PhaseProfile> {
221 let Some(maximum) = self
222 .phases
223 .iter()
224 .map(|phase| phase.findings_discovered)
225 .max()
226 else {
227 return Vec::new();
228 };
229 self.phases
230 .iter()
231 .filter(|phase| phase.findings_discovered == maximum)
232 .collect()
233 }
234}
235
236#[derive(Debug, Clone, Default)]
238pub struct PerformanceAnalyzer {
239 profiles: BTreeMap<String, ScanProfile>,
240}
241
242impl PerformanceAnalyzer {
243 pub fn new() -> Self {
244 Self::default()
245 }
246
247 pub fn record_profile(
249 &mut self,
250 profile: ScanProfile,
251 ) -> Result<Option<ScanProfile>, ScanProfile> {
252 if !profile.is_valid() {
253 return Err(profile);
254 }
255 Ok(self.profiles.insert(profile.scan_id.clone(), profile))
256 }
257
258 pub fn get_profile(&self, scan_id: &str) -> Option<&ScanProfile> {
259 self.profiles.get(scan_id)
260 }
261
262 pub fn get_profiles(&self) -> Vec<&ScanProfile> {
263 self.profiles.values().collect()
264 }
265
266 pub fn compare(&self, first_id: &str, second_id: &str) -> Option<ScanComparison> {
267 let first = self.profiles.get(first_id)?;
268 let second = self.profiles.get(second_id)?;
269 Some(ScanComparison {
270 first_scan_id: first_id.to_string(),
271 second_scan_id: second_id.to_string(),
272 duration: DurationComparison::between(
273 first.total_duration_ms,
274 second.total_duration_ms,
275 ),
276 findings: CountComparison::between(first.total_findings(), second.total_findings()),
277 response_request_ratio_difference_percentage_points: match (
278 first.response_request_ratio_percent(),
279 second.response_request_ratio_percent(),
280 ) {
281 (Some(first_ratio), Some(second_ratio)) => Some(second_ratio - first_ratio),
282 _ => None,
283 },
284 })
285 }
286
287 pub fn profile_count(&self) -> usize {
288 self.profiles.len()
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum DurationComparison {
295 FirstFasterBy(u64),
296 Equal,
297 SecondFasterBy(u64),
298}
299
300impl DurationComparison {
301 fn between(first_ms: u64, second_ms: u64) -> Self {
302 match first_ms.cmp(&second_ms) {
303 std::cmp::Ordering::Less => Self::FirstFasterBy(second_ms - first_ms),
304 std::cmp::Ordering::Equal => Self::Equal,
305 std::cmp::Ordering::Greater => Self::SecondFasterBy(first_ms - second_ms),
306 }
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312pub enum CountComparison {
313 FirstHigherBy(u128),
314 Equal,
315 SecondHigherBy(u128),
316}
317
318impl CountComparison {
319 fn between(first: u128, second: u128) -> Self {
320 match first.cmp(&second) {
321 std::cmp::Ordering::Less => Self::SecondHigherBy(second - first),
322 std::cmp::Ordering::Equal => Self::Equal,
323 std::cmp::Ordering::Greater => Self::FirstHigherBy(first - second),
324 }
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ScanComparison {
331 pub first_scan_id: String,
332 pub second_scan_id: String,
333 pub duration: DurationComparison,
334 pub findings: CountComparison,
335 #[serde(with = "optional_finite_f64")]
336 pub response_request_ratio_difference_percentage_points: Option<f64>,
337}
338
339mod optional_finite_f64 {
340 use serde::{Deserialize, Deserializer, Serializer};
341
342 pub fn serialize<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
343 where
344 S: Serializer,
345 {
346 match value {
347 Some(value) if value.is_finite() => serializer.serialize_some(value),
348 Some(_) => Err(serde::ser::Error::custom("value must be finite")),
349 None => serializer.serialize_none(),
350 }
351 }
352
353 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
354 where
355 D: Deserializer<'de>,
356 {
357 let value = Option::<f64>::deserialize(deserializer)?;
358 match value {
359 Some(value) if value.is_finite() => Ok(Some(value)),
360 Some(_) => Err(serde::de::Error::custom("value must be finite")),
361 None => Ok(None),
362 }
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct BenchmarkResult {
369 pub benchmark_id: String,
370 pub benchmark_name: String,
371 pub duration_samples_micros: Vec<u64>,
372}
373
374impl BenchmarkResult {
375 pub fn mean_duration_micros(&self) -> Option<f64> {
376 mean(&self.duration_samples_micros)
377 }
378
379 pub fn percentile_duration_micros(&self, percentile: f64) -> Option<u64> {
381 if self.duration_samples_micros.is_empty()
382 || !percentile.is_finite()
383 || !(0.0..=100.0).contains(&percentile)
384 {
385 return None;
386 }
387 let mut samples = self.duration_samples_micros.clone();
388 samples.sort_unstable();
389 let rank = ((percentile / 100.0) * samples.len() as f64).ceil() as usize;
390 Some(samples[rank.saturating_sub(1).min(samples.len() - 1)])
391 }
392}
393
394#[derive(Debug, Clone, Default)]
396pub struct BenchmarkSuite {
397 results: Vec<BenchmarkResult>,
398}
399
400impl BenchmarkSuite {
401 pub fn new() -> Self {
402 Self::default()
403 }
404
405 pub fn record_result(&mut self, result: BenchmarkResult) {
406 self.results.push(result);
407 }
408
409 pub fn get_result(&self, benchmark_id: &str) -> Option<&BenchmarkResult> {
410 self.results
411 .iter()
412 .find(|result| result.benchmark_id == benchmark_id)
413 }
414
415 pub fn get_results(&self) -> &[BenchmarkResult] {
416 &self.results
417 }
418
419 pub fn results_with_mean_above(&self, threshold_micros: f64) -> Vec<&BenchmarkResult> {
421 self.results
422 .iter()
423 .filter(|result| {
424 result
425 .mean_duration_micros()
426 .is_some_and(|mean| mean > threshold_micros)
427 })
428 .collect()
429 }
430
431 pub fn result_count(&self) -> usize {
432 self.results.len()
433 }
434}
435
436fn ratio_percent(numerator: u64, denominator: u64) -> Option<f64> {
437 ratio_percent_u128(u128::from(numerator), u128::from(denominator))
438}
439
440fn ratio_percent_u128(numerator: u128, denominator: u128) -> Option<f64> {
441 (denominator != 0).then_some((numerator as f64 / denominator as f64) * 100.0)
442}
443
444fn mean(samples: &[u64]) -> Option<f64> {
445 if samples.is_empty() {
446 return None;
447 }
448 let sum: u128 = samples.iter().map(|&sample| u128::from(sample)).sum();
449 Some(sum as f64 / samples.len() as f64)
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 fn phase(number: u8, duration_ms: u64, findings: u64) -> PhaseProfile {
457 PhaseProfile {
458 phase_number: number,
459 phase_name: format!("phase-{number}"),
460 start_time: 0,
461 end_time: duration_ms,
462 duration_ms,
463 requests_sent: 2,
464 responses_received: 1,
465 findings_discovered: findings,
466 error_count: 1,
467 response_time_samples_ms: vec![10, 30],
468 }
469 }
470
471 #[test]
472 fn ratios_and_means_are_derived_from_raw_measurements() {
473 let phase = phase(1, 40, 1);
474 assert_eq!(phase.response_request_ratio_percent(), Some(50.0));
475 assert_eq!(phase.findings_per_100_responses(), Some(100.0));
476 assert_eq!(phase.mean_response_time_ms(), Some(20.0));
477 }
478
479 #[test]
480 fn ties_return_every_tied_phase() {
481 let mut profile = ScanProfile::new("scan".into());
482 profile.add_phase(phase(1, 50, 2));
483 profile.add_phase(phase(2, 50, 2));
484
485 assert_eq!(profile.slowest_phases().len(), 2);
486 assert_eq!(profile.most_productive_phases().len(), 2);
487 }
488
489 #[test]
490 fn equal_profiles_are_reported_as_equal() {
491 let mut catalog = PerformanceAnalyzer::new();
492 let mut first = ScanProfile::new("first".into());
493 first.total_duration_ms = 100;
494 first.add_phase(phase(1, 100, 1));
495 let mut second = first.clone();
496 second.scan_id = "second".into();
497 catalog.record_profile(first).expect("valid profile");
498 catalog.record_profile(second).expect("valid profile");
499
500 let comparison = catalog.compare("first", "second").unwrap();
501 assert_eq!(comparison.duration, DurationComparison::Equal);
502 assert_eq!(comparison.findings, CountComparison::Equal);
503 assert_eq!(
504 comparison.response_request_ratio_difference_percentage_points,
505 Some(0.0)
506 );
507 }
508
509 #[test]
510 fn benchmark_statistics_require_raw_samples() {
511 let empty = BenchmarkResult {
512 benchmark_id: "empty".into(),
513 benchmark_name: "empty".into(),
514 duration_samples_micros: Vec::new(),
515 };
516 assert_eq!(empty.mean_duration_micros(), None);
517 assert_eq!(empty.percentile_duration_micros(95.0), None);
518
519 let result = BenchmarkResult {
520 benchmark_id: "fixture".into(),
521 benchmark_name: "fixture".into(),
522 duration_samples_micros: vec![40, 10, 30, 20],
523 };
524 assert_eq!(result.mean_duration_micros(), Some(25.0));
525 assert_eq!(result.percentile_duration_micros(50.0), Some(20));
526 assert_eq!(result.percentile_duration_micros(f64::NAN), None);
527 }
528
529 #[test]
530 fn resource_measurements_reject_nonfinite_and_out_of_range_wire_values() {
531 let metrics = ResourceMetrics {
532 memory_used_mb: f32::NAN,
533 ..ResourceMetrics::default()
534 };
535 assert!(!metrics.is_valid());
536 assert!(serde_json::to_string(&metrics).is_err());
537
538 let invalid_cpu = r#"{
539 "memory_used_mb": 1.0,
540 "memory_peak_mb": 1.0,
541 "cpu_usage_percent": 101.0,
542 "cpu_peak_percent": 1.0,
543 "disk_read_mb": 0.0,
544 "disk_write_mb": 0.0,
545 "network_in_mb": 0.0,
546 "network_out_mb": 0.0
547 }"#;
548 assert!(serde_json::from_str::<ResourceMetrics>(invalid_cpu).is_err());
549 }
550
551 #[test]
552 fn profile_catalog_rejects_invalid_resources_and_orders_by_id() {
553 let mut catalog = PerformanceAnalyzer::new();
554 let mut invalid = ScanProfile::new("invalid".into());
555 invalid.resources.cpu_peak_percent = f32::INFINITY;
556 assert!(catalog.record_profile(invalid).is_err());
557 assert_eq!(catalog.profile_count(), 0);
558
559 for id in ["zeta", "alpha"] {
560 catalog
561 .record_profile(ScanProfile::new(id.into()))
562 .expect("valid profile");
563 }
564 assert_eq!(
565 catalog
566 .get_profiles()
567 .into_iter()
568 .map(|profile| profile.scan_id.as_str())
569 .collect::<Vec<_>>(),
570 vec!["alpha", "zeta"]
571 );
572 }
573
574 #[test]
575 fn comparison_wire_rejects_nonfinite_values() {
576 let comparison = ScanComparison {
577 first_scan_id: "first".into(),
578 second_scan_id: "second".into(),
579 duration: DurationComparison::Equal,
580 findings: CountComparison::Equal,
581 response_request_ratio_difference_percentage_points: Some(f64::NAN),
582 };
583 assert!(serde_json::to_string(&comparison).is_err());
584 }
585}