Skip to main content

probador/
load_testing.rs

1//! Load Testing Module
2//!
3//! Implements the Load Testing features from the spec (Section D):
4//!
5//! - D.1: Command Interface (basic, scenario, ramp-up)
6//! - D.2: Load Test Scenario Format (YAML)
7//! - D.3: Load Test Results Format
8//! - D.4: Implementation Requirements
9
10#![allow(clippy::must_use_candidate)]
11#![allow(clippy::missing_panics_doc)]
12#![allow(clippy::missing_errors_doc)]
13#![allow(clippy::module_name_repetitions)]
14#![allow(clippy::missing_const_for_fn)]
15#![allow(clippy::struct_excessive_bools)]
16#![allow(clippy::cast_possible_truncation)]
17#![allow(clippy::cast_precision_loss)]
18#![allow(clippy::cast_lossless)]
19#![allow(clippy::cast_sign_loss)]
20#![allow(clippy::suboptimal_flops)]
21#![allow(clippy::format_push_string)]
22#![allow(clippy::uninlined_format_args)]
23#![allow(clippy::return_self_not_must_use)]
24#![allow(clippy::unwrap_used)]
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30// =============================================================================
31// D.4 Implementation Requirements
32// =============================================================================
33
34/// Load test configuration
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct LoadTestConfig {
37    /// Target URL to test
38    pub target_url: String,
39    /// User configuration
40    pub users: UserConfig,
41    /// Test duration in seconds
42    pub duration_secs: u64,
43    /// Optional scenario file path
44    pub scenario: Option<PathBuf>,
45    /// Output format
46    pub output: LoadTestOutputFormat,
47}
48
49impl LoadTestConfig {
50    /// Create a new load test config
51    pub fn new(target_url: &str, users: u32, duration_secs: u64) -> Self {
52        Self {
53            target_url: target_url.to_string(),
54            users: UserConfig::Fixed(users),
55            duration_secs,
56            scenario: None,
57            output: LoadTestOutputFormat::Text,
58        }
59    }
60
61    /// Create a ramp-up config
62    pub fn with_ramp(
63        target_url: &str,
64        start_users: u32,
65        end_users: u32,
66        ramp_secs: u64,
67        duration_secs: u64,
68    ) -> Self {
69        Self {
70            target_url: target_url.to_string(),
71            users: UserConfig::Ramp {
72                start: start_users,
73                end: end_users,
74                ramp_secs,
75            },
76            duration_secs,
77            scenario: None,
78            output: LoadTestOutputFormat::Text,
79        }
80    }
81
82    /// Create from scenario file
83    pub fn from_scenario(scenario_path: PathBuf) -> Self {
84        Self {
85            target_url: String::new(),
86            users: UserConfig::Fixed(1),
87            duration_secs: 0,
88            scenario: Some(scenario_path),
89            output: LoadTestOutputFormat::Text,
90        }
91    }
92}
93
94/// User configuration for load test
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(tag = "type")]
97pub enum UserConfig {
98    /// Fixed number of concurrent users
99    Fixed(u32),
100    /// Ramp from start to end users
101    Ramp {
102        /// Starting user count
103        start: u32,
104        /// Ending user count
105        end: u32,
106        /// Ramp duration in seconds
107        ramp_secs: u64,
108    },
109}
110
111impl UserConfig {
112    /// Get user count at a given time (seconds into test)
113    pub fn users_at(&self, elapsed_secs: u64) -> u32 {
114        match self {
115            Self::Fixed(users) => *users,
116            Self::Ramp {
117                start,
118                end,
119                ramp_secs,
120            } => {
121                if elapsed_secs >= *ramp_secs {
122                    *end
123                } else {
124                    let progress = elapsed_secs as f64 / *ramp_secs as f64;
125                    let range = (*end as i64 - *start as i64) as f64;
126                    (*start as f64 + range * progress) as u32
127                }
128            }
129        }
130    }
131}
132
133/// Output format for load test results
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
135pub enum LoadTestOutputFormat {
136    /// Human-readable text
137    #[default]
138    Text,
139    /// JSON output
140    Json,
141    /// CSV output
142    Csv,
143}
144
145// =============================================================================
146// D.2 Load Test Scenario Format
147// =============================================================================
148
149/// Load test scenario (from YAML)
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct LoadTestScenario {
152    /// Scenario name
153    pub name: String,
154    /// Description
155    pub description: String,
156    /// Citation reference
157    #[serde(default)]
158    pub citation: Option<String>,
159    /// Test stages
160    pub stages: Vec<LoadTestStage>,
161    /// Request definitions
162    pub requests: Vec<LoadTestRequest>,
163}
164
165impl LoadTestScenario {
166    /// Create a new scenario
167    pub fn new(name: &str, description: &str) -> Self {
168        Self {
169            name: name.to_string(),
170            description: description.to_string(),
171            citation: None,
172            stages: Vec::new(),
173            requests: Vec::new(),
174        }
175    }
176
177    /// Add a stage
178    pub fn add_stage(&mut self, stage: LoadTestStage) {
179        self.stages.push(stage);
180    }
181
182    /// Add a request
183    pub fn add_request(&mut self, request: LoadTestRequest) {
184        self.requests.push(request);
185    }
186
187    /// Calculate total duration in seconds
188    pub fn total_duration_secs(&self) -> u64 {
189        self.stages.iter().map(|s| s.duration_secs).sum()
190    }
191
192    /// Load from YAML string
193    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
194        serde_yaml_ng::from_str(yaml).map_err(|e| format!("Failed to parse YAML: {}", e))
195    }
196
197    /// Load from file
198    pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
199        let content = std::fs::read_to_string(path)?;
200        Self::from_yaml(&content)
201            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
202    }
203
204    /// Save to file
205    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
206        let content = serde_yaml_ng::to_string(self)
207            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
208        std::fs::write(path, content)
209    }
210}
211
212/// A stage in the load test
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct LoadTestStage {
215    /// Stage name
216    pub name: String,
217    /// Duration in seconds
218    pub duration_secs: u64,
219    /// Starting users (for ramp stages)
220    pub users_start: u32,
221    /// Ending users (for ramp stages, same as start for steady)
222    pub users_end: u32,
223}
224
225impl LoadTestStage {
226    /// Create a steady-state stage
227    pub fn steady(name: &str, duration_secs: u64, users: u32) -> Self {
228        Self {
229            name: name.to_string(),
230            duration_secs,
231            users_start: users,
232            users_end: users,
233        }
234    }
235
236    /// Create a ramp stage
237    pub fn ramp(name: &str, duration_secs: u64, start_users: u32, end_users: u32) -> Self {
238        Self {
239            name: name.to_string(),
240            duration_secs,
241            users_start: start_users,
242            users_end: end_users,
243        }
244    }
245
246    /// Check if this is a ramp stage
247    pub fn is_ramp(&self) -> bool {
248        self.users_start != self.users_end
249    }
250
251    /// Get users at time offset within stage
252    pub fn users_at(&self, offset_secs: u64) -> u32 {
253        if !self.is_ramp() || self.duration_secs == 0 {
254            return self.users_start;
255        }
256        let progress = (offset_secs as f64 / self.duration_secs as f64).min(1.0);
257        let range = (self.users_end as i64 - self.users_start as i64) as f64;
258        (self.users_start as f64 + range * progress) as u32
259    }
260}
261
262/// A request definition in the scenario
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct LoadTestRequest {
265    /// Request name
266    pub name: String,
267    /// HTTP method
268    pub method: HttpMethod,
269    /// Request path (relative to base URL)
270    pub path: String,
271    /// Request weight (probability of selection)
272    #[serde(default = "default_weight")]
273    pub weight: f64,
274    /// Headers to send
275    #[serde(default)]
276    pub headers: HashMap<String, String>,
277    /// Request body
278    #[serde(default)]
279    pub body: Option<String>,
280    /// Assertions to check
281    #[serde(default)]
282    pub assertions: Vec<LoadTestAssertion>,
283}
284
285fn default_weight() -> f64 {
286    1.0
287}
288
289impl LoadTestRequest {
290    /// Create a GET request
291    pub fn get(name: &str, path: &str) -> Self {
292        Self {
293            name: name.to_string(),
294            method: HttpMethod::Get,
295            path: path.to_string(),
296            weight: 1.0,
297            headers: HashMap::new(),
298            body: None,
299            assertions: Vec::new(),
300        }
301    }
302
303    /// Create a POST request
304    pub fn post(name: &str, path: &str, body: Option<String>) -> Self {
305        Self {
306            name: name.to_string(),
307            method: HttpMethod::Post,
308            path: path.to_string(),
309            weight: 1.0,
310            headers: HashMap::new(),
311            body,
312            assertions: Vec::new(),
313        }
314    }
315
316    /// Add an assertion
317    pub fn with_assertion(mut self, assertion: LoadTestAssertion) -> Self {
318        self.assertions.push(assertion);
319        self
320    }
321
322    /// Set weight
323    pub fn with_weight(mut self, weight: f64) -> Self {
324        self.weight = weight;
325        self
326    }
327}
328
329/// HTTP methods
330#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
331pub enum HttpMethod {
332    /// HTTP GET method
333    #[default]
334    Get,
335    /// HTTP POST method
336    Post,
337    /// HTTP PUT method
338    Put,
339    /// HTTP DELETE method
340    Delete,
341    /// HTTP PATCH method
342    Patch,
343    /// HTTP HEAD method
344    Head,
345    /// HTTP OPTIONS method
346    Options,
347}
348
349impl std::fmt::Display for HttpMethod {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        match self {
352            Self::Get => write!(f, "GET"),
353            Self::Post => write!(f, "POST"),
354            Self::Put => write!(f, "PUT"),
355            Self::Delete => write!(f, "DELETE"),
356            Self::Patch => write!(f, "PATCH"),
357            Self::Head => write!(f, "HEAD"),
358            Self::Options => write!(f, "OPTIONS"),
359        }
360    }
361}
362
363/// Assertion for load test
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(tag = "type")]
366pub enum LoadTestAssertion {
367    /// Status code check
368    Status {
369        /// Expected status code
370        expected: u16,
371    },
372    /// Latency percentile check
373    LatencyPercentile {
374        /// Percentile (0-100)
375        percentile: u8,
376        /// Max latency in milliseconds
377        max_ms: u64,
378    },
379    /// Header value check
380    Header {
381        /// Header name
382        name: String,
383        /// Expected value
384        expected: String,
385    },
386    /// Body contains check
387    BodyContains {
388        /// Expected substring
389        substring: String,
390    },
391}
392
393impl LoadTestAssertion {
394    /// Create status assertion
395    pub fn status(expected: u16) -> Self {
396        Self::Status { expected }
397    }
398
399    /// Create latency percentile assertion
400    pub fn latency_p95(max_ms: u64) -> Self {
401        Self::LatencyPercentile {
402            percentile: 95,
403            max_ms,
404        }
405    }
406
407    /// Create latency percentile assertion with custom percentile
408    pub fn latency_percentile(percentile: u8, max_ms: u64) -> Self {
409        Self::LatencyPercentile { percentile, max_ms }
410    }
411
412    /// Create header assertion
413    pub fn header(name: &str, expected: &str) -> Self {
414        Self::Header {
415            name: name.to_string(),
416            expected: expected.to_string(),
417        }
418    }
419
420    /// Create body contains assertion
421    pub fn body_contains(substring: &str) -> Self {
422        Self::BodyContains {
423            substring: substring.to_string(),
424        }
425    }
426
427    /// Get description of this assertion
428    pub fn description(&self) -> String {
429        match self {
430            Self::Status { expected } => format!("status == {}", expected),
431            Self::LatencyPercentile { percentile, max_ms } => {
432                format!("latency_p{} < {}ms", percentile, max_ms)
433            }
434            Self::Header { name, expected } => format!("{} == {}", name, expected),
435            Self::BodyContains { substring } => format!("body contains '{}'", substring),
436        }
437    }
438}
439
440// =============================================================================
441// D.3 Load Test Results
442// =============================================================================
443
444/// Load test results
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct LoadTestResult {
447    /// Scenario name
448    pub scenario_name: String,
449    /// Total duration in seconds
450    pub duration_secs: u64,
451    /// Total requests made
452    pub total_requests: u64,
453    /// Successful requests
454    pub successful_requests: u64,
455    /// Failed requests
456    pub failed_requests: u64,
457    /// Per-endpoint statistics
458    pub endpoint_stats: Vec<EndpointStats>,
459    /// Peak throughput (req/s)
460    pub peak_throughput: f64,
461    /// Peak throughput time (seconds into test)
462    pub peak_throughput_time: u64,
463    /// Average throughput (req/s)
464    pub avg_throughput: f64,
465    /// Resource usage stats
466    pub resource_usage: ResourceUsage,
467    /// Assertion results
468    pub assertion_results: Vec<AssertionResult>,
469    /// Errors encountered
470    pub errors: Vec<LoadTestError>,
471}
472
473impl LoadTestResult {
474    /// Create a new result
475    pub fn new(scenario_name: &str) -> Self {
476        Self {
477            scenario_name: scenario_name.to_string(),
478            duration_secs: 0,
479            total_requests: 0,
480            successful_requests: 0,
481            failed_requests: 0,
482            endpoint_stats: Vec::new(),
483            peak_throughput: 0.0,
484            peak_throughput_time: 0,
485            avg_throughput: 0.0,
486            resource_usage: ResourceUsage::default(),
487            assertion_results: Vec::new(),
488            errors: Vec::new(),
489        }
490    }
491
492    /// Calculate error rate as percentage
493    pub fn error_rate(&self) -> f64 {
494        if self.total_requests == 0 {
495            0.0
496        } else {
497            (self.failed_requests as f64 / self.total_requests as f64) * 100.0
498        }
499    }
500
501    /// Check if all assertions passed
502    pub fn all_assertions_passed(&self) -> bool {
503        self.assertion_results.iter().all(|r| r.passed)
504    }
505
506    /// Count passed assertions
507    pub fn passed_assertions(&self) -> usize {
508        self.assertion_results.iter().filter(|r| r.passed).count()
509    }
510
511    /// Count failed assertions
512    pub fn failed_assertions(&self) -> usize {
513        self.assertion_results.iter().filter(|r| !r.passed).count()
514    }
515}
516
517/// Per-endpoint statistics
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct EndpointStats {
520    /// Endpoint name
521    pub name: String,
522    /// Request count
523    pub count: u64,
524    /// p50 latency in ms
525    pub p50_ms: u64,
526    /// p95 latency in ms
527    pub p95_ms: u64,
528    /// p99 latency in ms
529    pub p99_ms: u64,
530    /// Error count
531    pub errors: u64,
532    /// Min latency in ms
533    pub min_ms: u64,
534    /// Max latency in ms
535    pub max_ms: u64,
536    /// Avg latency in ms
537    pub avg_ms: u64,
538}
539
540impl EndpointStats {
541    /// Create new endpoint stats
542    pub fn new(name: &str) -> Self {
543        Self {
544            name: name.to_string(),
545            count: 0,
546            p50_ms: 0,
547            p95_ms: 0,
548            p99_ms: 0,
549            errors: 0,
550            min_ms: u64::MAX,
551            max_ms: 0,
552            avg_ms: 0,
553        }
554    }
555
556    /// Create from latency samples
557    pub fn from_samples(name: &str, samples: &[u64], errors: u64) -> Self {
558        if samples.is_empty() {
559            return Self::new(name);
560        }
561
562        let mut sorted = samples.to_vec();
563        sorted.sort_unstable();
564
565        let count = sorted.len() as u64;
566        let sum: u64 = sorted.iter().sum();
567
568        Self {
569            name: name.to_string(),
570            count,
571            p50_ms: percentile(&sorted, 50),
572            p95_ms: percentile(&sorted, 95),
573            p99_ms: percentile(&sorted, 99),
574            errors,
575            min_ms: *sorted.first().unwrap_or(&0),
576            max_ms: *sorted.last().unwrap_or(&0),
577            avg_ms: sum / count,
578        }
579    }
580}
581
582/// Calculate percentile from sorted samples
583fn percentile(sorted: &[u64], p: u8) -> u64 {
584    if sorted.is_empty() {
585        return 0;
586    }
587    let idx = ((p as f64 / 100.0) * (sorted.len() - 1) as f64) as usize;
588    sorted[idx.min(sorted.len() - 1)]
589}
590
591/// Resource usage statistics
592#[derive(Debug, Clone, Serialize, Deserialize, Default)]
593pub struct ResourceUsage {
594    /// Average CPU usage percentage
595    pub avg_cpu_percent: f64,
596    /// Peak CPU usage percentage
597    pub peak_cpu_percent: f64,
598    /// Average memory in MB
599    pub avg_memory_mb: u64,
600    /// Peak memory in MB
601    pub peak_memory_mb: u64,
602}
603
604/// Assertion result
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct AssertionResult {
607    /// Endpoint name
608    pub endpoint: String,
609    /// Assertion description
610    pub assertion: String,
611    /// Whether it passed
612    pub passed: bool,
613    /// Actual value
614    pub actual: String,
615    /// Expected value
616    pub expected: String,
617}
618
619impl AssertionResult {
620    /// Create a passed assertion
621    pub fn passed(endpoint: &str, assertion: &str, actual: &str) -> Self {
622        Self {
623            endpoint: endpoint.to_string(),
624            assertion: assertion.to_string(),
625            passed: true,
626            actual: actual.to_string(),
627            expected: actual.to_string(),
628        }
629    }
630
631    /// Create a failed assertion
632    pub fn failed(endpoint: &str, assertion: &str, expected: &str, actual: &str) -> Self {
633        Self {
634            endpoint: endpoint.to_string(),
635            assertion: assertion.to_string(),
636            passed: false,
637            actual: actual.to_string(),
638            expected: expected.to_string(),
639        }
640    }
641}
642
643/// Load test error
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct LoadTestError {
646    /// Endpoint where error occurred
647    pub endpoint: String,
648    /// Error kind
649    pub kind: LoadTestErrorKind,
650    /// Error message
651    pub message: String,
652    /// Time of error (seconds into test)
653    pub time_secs: u64,
654}
655
656impl LoadTestError {
657    /// Create a new error
658    pub fn new(endpoint: &str, kind: LoadTestErrorKind, message: &str, time_secs: u64) -> Self {
659        Self {
660            endpoint: endpoint.to_string(),
661            kind,
662            message: message.to_string(),
663            time_secs,
664        }
665    }
666}
667
668/// Load test error kinds
669#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
670pub enum LoadTestErrorKind {
671    /// Connection failed
672    Connection,
673    /// Request timeout
674    Timeout,
675    /// HTTP error (non-2xx status)
676    HttpError,
677    /// DNS resolution failed
678    DnsError,
679    /// TLS/SSL error
680    TlsError,
681    /// Other error
682    Other,
683}
684
685impl std::fmt::Display for LoadTestErrorKind {
686    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687        match self {
688            Self::Connection => write!(f, "Connection"),
689            Self::Timeout => write!(f, "Timeout"),
690            Self::HttpError => write!(f, "HTTP Error"),
691            Self::DnsError => write!(f, "DNS Error"),
692            Self::TlsError => write!(f, "TLS Error"),
693            Self::Other => write!(f, "Other"),
694        }
695    }
696}
697
698// =============================================================================
699// Latency Histogram for percentile tracking
700// =============================================================================
701
702/// Latency histogram for tracking percentiles
703#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct LatencyHistogram {
705    /// Buckets (ms -> count)
706    buckets: Vec<u64>,
707    /// Bucket size in ms
708    bucket_size: u64,
709    /// Total samples
710    count: u64,
711    /// Sum of all samples
712    sum: u64,
713    /// Min value
714    min: u64,
715    /// Max value
716    max: u64,
717}
718
719impl LatencyHistogram {
720    /// Create a new histogram with bucket size in ms
721    pub fn new(bucket_size: u64) -> Self {
722        Self {
723            buckets: vec![0; 1000], // Up to bucket_size * 1000 ms
724            bucket_size,
725            count: 0,
726            sum: 0,
727            min: u64::MAX,
728            max: 0,
729        }
730    }
731
732    /// Record a latency sample in ms
733    pub fn record(&mut self, latency_ms: u64) {
734        let bucket = (latency_ms / self.bucket_size) as usize;
735        if bucket < self.buckets.len() {
736            self.buckets[bucket] += 1;
737        } else {
738            // Overflow bucket (last)
739            *self.buckets.last_mut().unwrap() += 1;
740        }
741        self.count += 1;
742        self.sum += latency_ms;
743        self.min = self.min.min(latency_ms);
744        self.max = self.max.max(latency_ms);
745    }
746
747    /// Get percentile value
748    pub fn percentile(&self, p: u8) -> u64 {
749        if self.count == 0 {
750            return 0;
751        }
752
753        let target = ((p as f64 / 100.0) * self.count as f64) as u64;
754        let mut cumulative = 0u64;
755
756        for (i, &count) in self.buckets.iter().enumerate() {
757            cumulative += count;
758            if cumulative >= target {
759                return (i as u64 + 1) * self.bucket_size;
760            }
761        }
762
763        self.max
764    }
765
766    /// Get mean latency
767    pub fn mean(&self) -> u64 {
768        if self.count == 0 {
769            0
770        } else {
771            self.sum / self.count
772        }
773    }
774
775    /// Get sample count
776    pub fn count(&self) -> u64 {
777        self.count
778    }
779
780    /// Get min value
781    pub fn min(&self) -> u64 {
782        if self.count == 0 {
783            0
784        } else {
785            self.min
786        }
787    }
788
789    /// Get max value
790    pub fn max(&self) -> u64 {
791        self.max
792    }
793}
794
795impl Default for LatencyHistogram {
796    fn default() -> Self {
797        Self::new(1) // 1ms buckets
798    }
799}
800
801// =============================================================================
802// Rendering
803// =============================================================================
804
805/// Render load test results as text
806pub fn render_load_test_report(result: &LoadTestResult) -> String {
807    let mut output = String::new();
808
809    output.push_str(&format!("LOAD TEST RESULTS: {}\n", result.scenario_name));
810    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
811
812    output.push_str(&format!(
813        "Duration: {}s │ Total Requests: {} │ Failed: {} ({:.2}%)\n\n",
814        result.duration_secs,
815        result.total_requests,
816        result.failed_requests,
817        result.error_rate()
818    ));
819
820    // Request Statistics table
821    output.push_str("Request Statistics:\n");
822    output.push_str("┌─────────────────┬─────────┬─────────┬─────────┬─────────┬─────────┐\n");
823    output.push_str("│ Endpoint        │ Count   │ p50     │ p95     │ p99     │ Errors  │\n");
824    output.push_str("├─────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┤\n");
825
826    for stat in &result.endpoint_stats {
827        output.push_str(&format!(
828            "│ {:<15} │ {:>7} │ {:>5}ms │ {:>5}ms │ {:>5}ms │ {:>7} │\n",
829            truncate(&stat.name, 15),
830            stat.count,
831            stat.p50_ms,
832            stat.p95_ms,
833            stat.p99_ms,
834            stat.errors
835        ));
836    }
837    output.push_str("└─────────────────┴─────────┴─────────┴─────────┴─────────┴─────────┘\n\n");
838
839    // Throughput
840    output.push_str("Throughput:\n");
841    output.push_str(&format!(
842        "  Peak: {:.0} req/s at t={}s\n",
843        result.peak_throughput, result.peak_throughput_time
844    ));
845    output.push_str(&format!("  Avg:  {:.0} req/s\n\n", result.avg_throughput));
846
847    // Resource Usage
848    output.push_str("Resource Usage:\n");
849    output.push_str(&format!(
850        "  Server CPU: avg {:.0}%, peak {:.0}%\n",
851        result.resource_usage.avg_cpu_percent, result.resource_usage.peak_cpu_percent
852    ));
853    output.push_str(&format!(
854        "  Server Memory: avg {}MB, peak {}MB\n\n",
855        result.resource_usage.avg_memory_mb, result.resource_usage.peak_memory_mb
856    ));
857
858    // Assertions
859    output.push_str("Assertions:\n");
860    for assertion in &result.assertion_results {
861        let symbol = if assertion.passed { "✓" } else { "✗" };
862        output.push_str(&format!(
863            "  {} {} {} (actual: {})\n",
864            symbol, assertion.endpoint, assertion.assertion, assertion.actual
865        ));
866    }
867
868    output
869}
870
871/// Render load test results as JSON
872pub fn render_load_test_json(result: &LoadTestResult) -> String {
873    serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
874}
875
876/// Truncate string to max length
877fn truncate(s: &str, max_len: usize) -> String {
878    if s.len() <= max_len {
879        s.to_string()
880    } else {
881        format!("{}…", &s[..max_len - 1])
882    }
883}
884
885// =============================================================================
886// Tests
887// =============================================================================
888
889#[cfg(test)]
890#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn test_load_test_config_new() {
896        let config = LoadTestConfig::new("http://localhost:8080", 100, 30);
897        assert_eq!(config.target_url, "http://localhost:8080");
898        assert_eq!(config.users, UserConfig::Fixed(100));
899        assert_eq!(config.duration_secs, 30);
900    }
901
902    #[test]
903    fn test_load_test_config_ramp() {
904        let config = LoadTestConfig::with_ramp("http://localhost:8080", 1, 100, 60, 120);
905        assert_eq!(
906            config.users,
907            UserConfig::Ramp {
908                start: 1,
909                end: 100,
910                ramp_secs: 60
911            }
912        );
913    }
914
915    #[test]
916    fn test_user_config_fixed() {
917        let config = UserConfig::Fixed(50);
918        assert_eq!(config.users_at(0), 50);
919        assert_eq!(config.users_at(30), 50);
920        assert_eq!(config.users_at(100), 50);
921    }
922
923    #[test]
924    fn test_user_config_ramp() {
925        let config = UserConfig::Ramp {
926            start: 10,
927            end: 100,
928            ramp_secs: 90,
929        };
930        assert_eq!(config.users_at(0), 10);
931        assert_eq!(config.users_at(45), 55); // Midpoint
932        assert_eq!(config.users_at(90), 100);
933        assert_eq!(config.users_at(100), 100); // Past ramp
934    }
935
936    #[test]
937    fn test_scenario_new() {
938        let mut scenario = LoadTestScenario::new("Test", "A test scenario");
939        scenario.add_stage(LoadTestStage::steady("warmup", 30, 10));
940        scenario.add_stage(LoadTestStage::ramp("ramp", 60, 10, 100));
941
942        assert_eq!(scenario.stages.len(), 2);
943        assert_eq!(scenario.total_duration_secs(), 90);
944    }
945
946    #[test]
947    fn test_load_test_stage_steady() {
948        let stage = LoadTestStage::steady("steady", 60, 50);
949        assert!(!stage.is_ramp());
950        assert_eq!(stage.users_at(0), 50);
951        assert_eq!(stage.users_at(30), 50);
952    }
953
954    #[test]
955    fn test_load_test_stage_ramp() {
956        let stage = LoadTestStage::ramp("ramp", 60, 10, 100);
957        assert!(stage.is_ramp());
958        assert_eq!(stage.users_at(0), 10);
959        assert_eq!(stage.users_at(30), 55);
960        assert_eq!(stage.users_at(60), 100);
961    }
962
963    #[test]
964    fn test_load_test_request_get() {
965        let request = LoadTestRequest::get("home", "/")
966            .with_assertion(LoadTestAssertion::status(200))
967            .with_weight(2.0);
968
969        assert_eq!(request.name, "home");
970        assert_eq!(request.method, HttpMethod::Get);
971        assert_eq!(request.weight, 2.0);
972        assert_eq!(request.assertions.len(), 1);
973    }
974
975    #[test]
976    fn test_http_method_display() {
977        assert_eq!(HttpMethod::Get.to_string(), "GET");
978        assert_eq!(HttpMethod::Post.to_string(), "POST");
979        assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
980    }
981
982    #[test]
983    fn test_assertion_description() {
984        assert_eq!(
985            LoadTestAssertion::status(200).description(),
986            "status == 200"
987        );
988        assert_eq!(
989            LoadTestAssertion::latency_p95(100).description(),
990            "latency_p95 < 100ms"
991        );
992        assert_eq!(
993            LoadTestAssertion::header("content-type", "application/json").description(),
994            "content-type == application/json"
995        );
996    }
997
998    #[test]
999    fn test_load_test_result_error_rate() {
1000        let mut result = LoadTestResult::new("Test");
1001        result.total_requests = 1000;
1002        result.failed_requests = 10;
1003
1004        assert!((result.error_rate() - 1.0).abs() < 0.001);
1005    }
1006
1007    #[test]
1008    fn test_load_test_result_assertions() {
1009        let mut result = LoadTestResult::new("Test");
1010        result
1011            .assertion_results
1012            .push(AssertionResult::passed("ep1", "status", "200"));
1013        result
1014            .assertion_results
1015            .push(AssertionResult::passed("ep2", "status", "200"));
1016        result
1017            .assertion_results
1018            .push(AssertionResult::failed("ep3", "latency", "100ms", "200ms"));
1019
1020        assert_eq!(result.passed_assertions(), 2);
1021        assert_eq!(result.failed_assertions(), 1);
1022        assert!(!result.all_assertions_passed());
1023    }
1024
1025    #[test]
1026    fn test_endpoint_stats_from_samples() {
1027        let samples = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
1028        let stats = EndpointStats::from_samples("test", &samples, 2);
1029
1030        assert_eq!(stats.count, 10);
1031        assert_eq!(stats.min_ms, 10);
1032        assert_eq!(stats.max_ms, 100);
1033        assert_eq!(stats.avg_ms, 55);
1034        assert_eq!(stats.errors, 2);
1035    }
1036
1037    #[test]
1038    fn test_percentile() {
1039        let sorted = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1040        assert_eq!(percentile(&sorted, 50), 5);
1041        assert_eq!(percentile(&sorted, 90), 9);
1042        assert_eq!(percentile(&sorted, 100), 10);
1043    }
1044
1045    #[test]
1046    fn test_latency_histogram() {
1047        let mut hist = LatencyHistogram::new(10);
1048
1049        for i in 0..100 {
1050            hist.record(i);
1051        }
1052
1053        assert_eq!(hist.count(), 100);
1054        assert_eq!(hist.min(), 0);
1055        assert_eq!(hist.max(), 99);
1056    }
1057
1058    #[test]
1059    fn test_latency_histogram_percentile() {
1060        let mut hist = LatencyHistogram::new(1);
1061
1062        for i in 1..=100 {
1063            hist.record(i);
1064        }
1065
1066        // Percentiles from histogram (bucketed, so may be slightly off)
1067        assert!(hist.percentile(50) >= 45 && hist.percentile(50) <= 55);
1068        assert!(hist.percentile(95) >= 90);
1069    }
1070
1071    #[test]
1072    fn test_load_test_error() {
1073        let error = LoadTestError::new(
1074            "endpoint",
1075            LoadTestErrorKind::Timeout,
1076            "Request timed out",
1077            45,
1078        );
1079        assert_eq!(error.endpoint, "endpoint");
1080        assert_eq!(error.kind, LoadTestErrorKind::Timeout);
1081        assert_eq!(error.time_secs, 45);
1082    }
1083
1084    #[test]
1085    fn test_error_kind_display() {
1086        assert_eq!(LoadTestErrorKind::Connection.to_string(), "Connection");
1087        assert_eq!(LoadTestErrorKind::Timeout.to_string(), "Timeout");
1088        assert_eq!(LoadTestErrorKind::HttpError.to_string(), "HTTP Error");
1089    }
1090
1091    #[test]
1092    fn test_render_load_test_report() {
1093        let mut result = LoadTestResult::new("Test Scenario");
1094        result.duration_secs = 60;
1095        result.total_requests = 1000;
1096        result.successful_requests = 990;
1097        result.failed_requests = 10;
1098        result.avg_throughput = 16.67;
1099        result.peak_throughput = 25.0;
1100        result.peak_throughput_time = 30;
1101
1102        result.endpoint_stats.push(EndpointStats {
1103            name: "homepage".to_string(),
1104            count: 500,
1105            p50_ms: 12,
1106            p95_ms: 45,
1107            p99_ms: 89,
1108            errors: 5,
1109            min_ms: 5,
1110            max_ms: 120,
1111            avg_ms: 25,
1112        });
1113
1114        result
1115            .assertion_results
1116            .push(AssertionResult::passed("homepage", "status == 200", "200"));
1117
1118        let report = render_load_test_report(&result);
1119        assert!(report.contains("Test Scenario"));
1120        assert!(report.contains("Duration: 60s"));
1121        assert!(report.contains("homepage"));
1122        assert!(report.contains("✓"));
1123    }
1124
1125    #[test]
1126    fn test_render_load_test_json() {
1127        let result = LoadTestResult::new("JSON Test");
1128        let json = render_load_test_json(&result);
1129        assert!(json.contains("JSON Test"));
1130        assert!(json.contains("scenario_name"));
1131    }
1132
1133    #[test]
1134    fn test_truncate() {
1135        assert_eq!(truncate("short", 10), "short");
1136        assert_eq!(truncate("this is very long", 10), "this is v…");
1137    }
1138
1139    #[test]
1140    fn test_scenario_yaml_roundtrip() {
1141        let mut scenario = LoadTestScenario::new("YAML Test", "Testing YAML serialization");
1142        scenario.add_stage(LoadTestStage::steady("warmup", 10, 5));
1143        scenario.add_request(LoadTestRequest::get("home", "/"));
1144
1145        let yaml = serde_yaml_ng::to_string(&scenario).unwrap();
1146        let parsed: LoadTestScenario = serde_yaml_ng::from_str(&yaml).unwrap();
1147
1148        assert_eq!(parsed.name, "YAML Test");
1149        assert_eq!(parsed.stages.len(), 1);
1150        assert_eq!(parsed.requests.len(), 1);
1151    }
1152
1153    #[test]
1154    fn test_load_test_config_from_scenario() {
1155        let config = LoadTestConfig::from_scenario(PathBuf::from("scenario.yaml"));
1156        assert!(config.target_url.is_empty());
1157        assert_eq!(config.users, UserConfig::Fixed(1));
1158        assert_eq!(config.duration_secs, 0);
1159        assert_eq!(config.scenario, Some(PathBuf::from("scenario.yaml")));
1160    }
1161
1162    #[test]
1163    fn test_load_test_assertion_body_contains() {
1164        let assertion = LoadTestAssertion::body_contains("success");
1165        assert_eq!(assertion.description(), "body contains 'success'");
1166    }
1167
1168    #[test]
1169    fn test_load_test_assertion_latency_percentile() {
1170        let assertion = LoadTestAssertion::latency_percentile(99, 500);
1171        assert_eq!(assertion.description(), "latency_p99 < 500ms");
1172    }
1173
1174    #[test]
1175    fn test_load_test_result_error_rate_zero() {
1176        let result = LoadTestResult::new("Zero Requests");
1177        assert_eq!(result.error_rate(), 0.0);
1178    }
1179
1180    #[test]
1181    fn test_load_test_result_all_assertions_passed_empty() {
1182        let result = LoadTestResult::new("No Assertions");
1183        assert!(result.all_assertions_passed());
1184    }
1185
1186    #[test]
1187    fn test_endpoint_stats_new() {
1188        let stats = EndpointStats::new("api/v1");
1189        assert_eq!(stats.name, "api/v1");
1190        assert_eq!(stats.count, 0);
1191    }
1192
1193    #[test]
1194    fn test_endpoint_stats_from_empty_samples() {
1195        let stats = EndpointStats::from_samples("empty", &[], 0);
1196        assert_eq!(stats.count, 0);
1197    }
1198
1199    #[test]
1200    fn test_percentile_empty() {
1201        let empty: Vec<u64> = vec![];
1202        assert_eq!(percentile(&empty, 50), 0);
1203    }
1204
1205    #[test]
1206    fn test_http_method_all_variants() {
1207        assert_eq!(HttpMethod::Get.to_string(), "GET");
1208        assert_eq!(HttpMethod::Post.to_string(), "POST");
1209        assert_eq!(HttpMethod::Put.to_string(), "PUT");
1210        assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
1211        assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
1212        assert_eq!(HttpMethod::Head.to_string(), "HEAD");
1213        assert_eq!(HttpMethod::Options.to_string(), "OPTIONS");
1214    }
1215
1216    #[test]
1217    fn test_error_kind_all_variants() {
1218        assert_eq!(LoadTestErrorKind::Connection.to_string(), "Connection");
1219        assert_eq!(LoadTestErrorKind::Timeout.to_string(), "Timeout");
1220        assert_eq!(LoadTestErrorKind::HttpError.to_string(), "HTTP Error");
1221        assert_eq!(LoadTestErrorKind::DnsError.to_string(), "DNS Error");
1222        assert_eq!(LoadTestErrorKind::TlsError.to_string(), "TLS Error");
1223        assert_eq!(LoadTestErrorKind::Other.to_string(), "Other");
1224    }
1225
1226    #[test]
1227    fn test_load_test_request_post() {
1228        let request = LoadTestRequest::post(
1229            "create_user",
1230            "/users",
1231            Some(r#"{"name": "test"}"#.to_string()),
1232        );
1233        assert_eq!(request.method, HttpMethod::Post);
1234        assert!(request.body.is_some());
1235    }
1236
1237    #[test]
1238    fn test_load_test_request_post_no_body() {
1239        let request = LoadTestRequest::post("create_empty", "/items", None);
1240        assert_eq!(request.method, HttpMethod::Post);
1241        assert!(request.body.is_none());
1242    }
1243
1244    #[test]
1245    fn test_latency_histogram_empty_percentile() {
1246        let hist = LatencyHistogram::new(10);
1247        assert_eq!(hist.percentile(50), 0);
1248    }
1249
1250    #[test]
1251    fn test_latency_histogram_empty_stats() {
1252        let hist = LatencyHistogram::new(10);
1253        assert_eq!(hist.count(), 0);
1254        assert_eq!(hist.min(), 0); // min() returns 0 for empty histogram
1255        assert_eq!(hist.max(), 0);
1256    }
1257
1258    #[test]
1259    fn test_latency_histogram_overflow() {
1260        let mut hist = LatencyHistogram::new(10); // 10 buckets
1261        hist.record(10000); // Way beyond 10 buckets
1262        assert_eq!(hist.count(), 1);
1263    }
1264
1265    #[test]
1266    fn test_assertion_result_passed() {
1267        let result = AssertionResult::passed("endpoint", "status == 200", "200");
1268        assert!(result.passed);
1269        assert_eq!(result.endpoint, "endpoint");
1270    }
1271
1272    #[test]
1273    fn test_assertion_result_failed() {
1274        let result = AssertionResult::failed("endpoint", "latency_p95 < 100ms", "100ms", "150ms");
1275        assert!(!result.passed);
1276        assert!(!result.expected.is_empty());
1277        assert!(!result.actual.is_empty());
1278    }
1279
1280    #[test]
1281    fn test_resource_usage_default() {
1282        let usage = ResourceUsage::default();
1283        assert_eq!(usage.avg_cpu_percent, 0.0);
1284        assert_eq!(usage.peak_cpu_percent, 0.0);
1285    }
1286
1287    #[test]
1288    fn test_load_test_output_format_default() {
1289        let format = LoadTestOutputFormat::default();
1290        assert!(matches!(format, LoadTestOutputFormat::Text));
1291    }
1292}