1use crate::chunked_bench::{run, ChunkedBenchConfig, ChunkedBenchResult};
18use crate::request_gen::RequestGenerator;
19use crate::spec_parser::SpecParser;
20use crate::target_parser::{parse_targets_file, TargetConfig};
21use anyhow::{bail, Context};
22use futures::StreamExt;
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27#[derive(Debug, Clone)]
29pub struct ChunkedCommand {
30 pub target: Option<String>,
32 pub targets_file: Option<PathBuf>,
34 pub max_concurrency: u32,
36 pub spec: Option<PathBuf>,
37 pub base_path: Option<String>,
38 pub operation_id: Option<String>,
39 pub method: String,
40 pub concurrency: u32,
41 pub duration: Duration,
42 pub chunk_size_bytes: usize,
43 pub total_size_bytes: usize,
44 pub chunk_interval_ms: u64,
45 pub headers: HashMap<String, String>,
46 pub insecure: bool,
47 pub validate_requests: bool,
48 pub export_requests: bool,
49 pub output: PathBuf,
50}
51
52#[derive(Debug, Clone)]
54struct TargetRun {
55 prefix: String,
57 url: String,
58 headers: HashMap<String, String>,
59 spec: Option<PathBuf>,
60 output: PathBuf,
61}
62
63#[derive(Debug, Clone, Default)]
65struct TargetOutcome {
66 total_requests: u64,
67 successful: u64,
68 failed: u64,
69 bytes_sent: u64,
70 status_counts: HashMap<u16, u64>,
71 run_failed: bool,
73}
74
75impl TargetOutcome {
76 fn absorb(&mut self, r: &ChunkedBenchResult) {
77 self.total_requests += r.total_requests;
78 self.successful += r.successful;
79 self.failed += r.failed;
80 self.bytes_sent += r.bytes_sent;
81 for (code, n) in &r.status_counts {
82 *self.status_counts.entry(*code).or_insert(0) += n;
83 }
84 }
85}
86
87impl ChunkedCommand {
88 pub async fn execute(&self) -> anyhow::Result<bool> {
91 match (&self.target, &self.targets_file) {
92 (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
93 (None, None) => bail!("either --target or --targets-file is required"),
94 (Some(target), None) => {
95 let run = TargetRun {
96 prefix: String::new(),
97 url: target.clone(),
98 headers: self.headers.clone(),
99 spec: self.spec.clone(),
100 output: self.output.clone(),
101 };
102 Ok(!self.run_target(&run).await?.run_failed)
103 }
104 (None, Some(file)) => self.execute_multi_target(file).await,
105 }
106 }
107
108 async fn execute_multi_target(&self, file: &Path) -> anyhow::Result<bool> {
109 let targets = parse_targets_file(file)
110 .map_err(|e| anyhow::anyhow!("{e}"))
111 .with_context(|| format!("failed to read targets file {}", file.display()))?;
112 if targets.is_empty() {
113 bail!("no targets found in {}", file.display());
114 }
115 let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), &self.output);
116 let max_concurrency = self.max_concurrency.max(1) as usize;
117 println!(
118 "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
119 runs.len(),
120 file,
121 max_concurrency
122 );
123
124 let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
125 futures::stream::iter(runs.into_iter().enumerate())
126 .map(|(i, run)| async move {
127 let res = self.run_target(&run).await;
128 (i, run, res)
129 })
130 .buffer_unordered(max_concurrency)
131 .collect()
132 .await;
133 outcomes.sort_by_key(|(i, _, _)| *i);
134
135 println!();
136 println!("Multi-target chunked summary:");
137 let mut all_ok = true;
138 let mut rows = Vec::with_capacity(outcomes.len());
139 for (i, run, res) in &outcomes {
140 let name = format!("target_{}", i + 1);
141 match res {
142 Ok(o) => {
143 if o.run_failed {
144 all_ok = false;
145 }
146 println!(
147 " {:<10} {} requests={} ok={} failed={} bytes={}{}",
148 name,
149 run.url,
150 o.total_requests,
151 o.successful,
152 o.failed,
153 o.bytes_sent,
154 if o.run_failed {
155 " (some operations failed to run)"
156 } else {
157 ""
158 }
159 );
160 rows.push(serde_json::json!({
161 "target": name,
162 "url": run.url,
163 "spec": run.spec.as_ref().map(|p| p.display().to_string()),
164 "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
166 "total_requests": o.total_requests,
167 "successful": o.successful,
168 "failed": o.failed,
169 "bytes_sent": o.bytes_sent,
170 "status_counts": o.status_counts,
171 "run_failed": o.run_failed,
172 }));
173 }
174 Err(e) => {
175 all_ok = false;
176 println!(" {:<10} {} ERROR: {:#}", name, run.url, e);
177 rows.push(serde_json::json!({
178 "target": name,
179 "url": run.url,
180 "error": format!("{e:#}"),
181 }));
182 }
183 }
184 }
185
186 std::fs::create_dir_all(&self.output)
187 .with_context(|| format!("failed to create output directory {:?}", self.output))?;
188 let path = self.output.join("chunked-multi-target-summary.json");
189 let payload = serde_json::json!({
190 "targets_file": file.display().to_string(),
191 "concurrency_per_target": self.concurrency,
192 "max_concurrency": max_concurrency,
193 "duration_secs": self.duration.as_secs(),
194 "chunk_size_bytes": self.chunk_size_bytes,
195 "total_size_bytes": self.total_size_bytes,
196 "chunk_interval_ms": self.chunk_interval_ms,
197 "targets": rows,
198 });
199 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
200 .with_context(|| format!("failed to write {:?}", path))?;
201 println!("📝 Wrote {:?}", path);
202 Ok(all_ok)
203 }
204
205 fn bench_config(
206 &self,
207 url: String,
208 method: reqwest::Method,
209 headers: &HashMap<String, String>,
210 ) -> ChunkedBenchConfig {
211 ChunkedBenchConfig {
212 target_url: url,
213 method,
214 concurrency: self.concurrency,
215 duration: self.duration,
216 chunk_size_bytes: self.chunk_size_bytes,
217 total_size_bytes: self.total_size_bytes,
218 chunk_interval_ms: self.chunk_interval_ms,
219 headers: headers.clone(),
220 skip_tls_verify: self.insecure,
221 }
222 }
223
224 async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
225 match &t.spec {
226 Some(spec) => self.run_spec(t, spec).await,
227 None => self.run_single(t).await,
228 }
229 }
230
231 async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
232 let p = &t.prefix;
233 if p.is_empty() {
235 if self.base_path.is_some() {
236 eprintln!(
237 "Note: --base-path has no effect without --spec; \
238 single-target mode uses --target verbatim"
239 );
240 }
241 if self.validate_requests {
242 eprintln!("Note: --validate-requests requires --spec (skipped)");
243 }
244 if self.export_requests {
245 eprintln!("Note: --export-requests requires --spec (skipped)");
246 }
247 }
248 let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
249 .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
250 if !p.is_empty() {
251 println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
252 }
253 let r = run(self.bench_config(t.url.clone(), method, &t.headers))
254 .await
255 .context("chunked bench failed")?;
256 print_result(
257 p,
258 if p.is_empty() {
259 "single-target"
260 } else {
261 &t.url
262 },
263 &r,
264 );
265 let mut outcome = TargetOutcome::default();
266 outcome.absorb(&r);
267 Ok(outcome)
268 }
269
270 async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
271 let p = &t.prefix;
272 let parser = SpecParser::from_file(spec_path)
273 .await
274 .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
275 let base_url = t.url.trim_end_matches('/').to_string();
276 let effective_base_path: Option<String> = match &self.base_path {
279 Some(bp) if bp.is_empty() => None,
280 Some(bp) => Some(bp.clone()),
281 None => parser.get_base_path(),
282 };
283 let want_methods = ["POST", "PUT", "PATCH"];
284 let ops: Vec<_> = parser
285 .get_operations()
286 .into_iter()
287 .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
288 .filter(|op| match &self.operation_id {
289 Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
290 None => true,
291 })
292 .collect();
293 if ops.is_empty() {
294 bail!(
295 "No POST/PUT/PATCH operations matched in {:?}{}",
296 spec_path,
297 self.operation_id
298 .as_deref()
299 .map(|id| format!(" (operation-id={id})"))
300 .unwrap_or_default()
301 );
302 }
303
304 let mut violations: Vec<serde_json::Value> = Vec::new();
307 let mut planned: Vec<(String, String, String, String)> = Vec::with_capacity(ops.len());
309 for op in &ops {
310 let label = op.display_name();
311 match RequestGenerator::generate_template(op) {
312 Ok(template) => {
313 let path_with_base = match &effective_base_path {
314 Some(bp) if !bp.is_empty() => {
315 format!("{}{}", bp, template.generate_path())
316 }
317 _ => template.generate_path().to_string(),
318 };
319 let url = format!("{}{}", base_url, path_with_base);
320 if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
321 violations.push(serde_json::json!({
322 "operation": label,
323 "method": op.method,
324 "path": op.path,
325 "kind": "invalid_method",
326 "detail": format!("`{}` is not a valid HTTP method", op.method),
327 }));
328 } else {
329 planned.push((label, op.method.to_uppercase(), url, op.path.clone()));
330 }
331 }
332 Err(e) => {
333 violations.push(serde_json::json!({
334 "operation": label,
335 "method": op.method,
336 "path": op.path,
337 "kind": "template_build_failure",
338 "detail": e.to_string(),
339 }));
340 }
341 }
342 }
343
344 if self.validate_requests {
345 if !violations.is_empty() {
346 std::fs::create_dir_all(&t.output)
347 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
348 let path = t.output.join("chunked-request-violations.json");
349 let payload = serde_json::json!({
350 "spec": spec_path.display().to_string(),
351 "base_url": base_url,
352 "base_path": effective_base_path,
353 "violations": violations,
354 });
355 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
356 .with_context(|| format!("Failed to write {:?}", path))?;
357 bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
358 }
359 println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
360 }
361
362 println!(
363 "{p}→ {} chunked bench {} from {:?} (base {}{})",
364 planned.len(),
365 if planned.len() == 1 {
366 "operation"
367 } else {
368 "operations"
369 },
370 spec_path,
371 base_url,
372 effective_base_path
373 .as_deref()
374 .map(|bp| format!(", base-path {}", bp))
375 .unwrap_or_default()
376 );
377
378 let mut outcome = TargetOutcome::default();
379 let mut export_records: Vec<serde_json::Value> = Vec::new();
380 for (label, method_str, url, op_path) in &planned {
381 let method = match reqwest::Method::from_bytes(method_str.as_bytes()) {
382 Ok(m) => m,
383 Err(_) => {
384 eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
385 outcome.run_failed = true;
386 continue;
387 }
388 };
389 println!();
390 println!("{p}→ {} {} ({})", method_str, op_path, url);
391 match run(self.bench_config(url.clone(), method, &t.headers)).await {
392 Ok(r) => {
393 print_result(p, label, &r);
394 outcome.absorb(&r);
395 if self.export_requests {
396 export_records.push(export_record(
397 self, label, method_str, url, op_path, &t.headers, &r,
398 ));
399 }
400 }
401 Err(e) => {
402 eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
403 outcome.run_failed = true;
404 }
405 }
406 }
407
408 if self.export_requests {
409 std::fs::create_dir_all(&t.output)
410 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
411 let path = t.output.join("chunked-requests.json");
412 let payload = serde_json::json!({
413 "spec": spec_path.display().to_string(),
414 "base_url": base_url,
415 "base_path": effective_base_path,
416 "operations": export_records,
417 });
418 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
419 .with_context(|| format!("Failed to write {:?}", path))?;
420 println!(
421 "{p}📝 --export-requests: wrote {} operations to {:?}",
422 export_records.len(),
423 path
424 );
425 }
426
427 Ok(outcome)
428 }
429}
430
431fn plan_target_runs(
435 targets: Vec<TargetConfig>,
436 cli_headers: &HashMap<String, String>,
437 cli_spec: Option<&PathBuf>,
438 output: &Path,
439) -> Vec<TargetRun> {
440 targets
441 .into_iter()
442 .enumerate()
443 .map(|(i, mut t)| {
444 t.normalize_url();
445 let mut headers = cli_headers.clone();
446 if let Some(h) = &t.headers {
447 headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
448 }
449 if let Some(auth) = &t.auth {
450 headers.insert("Authorization".to_string(), auth.clone());
451 }
452 let name = format!("target_{}", i + 1);
453 TargetRun {
454 prefix: format!("[{name}] "),
455 url: t.url,
456 headers,
457 spec: t.spec.or_else(|| cli_spec.cloned()),
458 output: output.join(name),
459 }
460 })
461 .collect()
462}
463
464fn export_record(
465 cmd: &ChunkedCommand,
466 label: &str,
467 method: &str,
468 url: &str,
469 op_path: &str,
470 headers: &HashMap<String, String>,
471 r: &ChunkedBenchResult,
472) -> serde_json::Value {
473 serde_json::json!({
474 "operation": label,
475 "method": method,
476 "url": url,
477 "spec_path": op_path,
478 "headers": headers,
479 "chunk_size_bytes": cmd.chunk_size_bytes,
480 "total_size_bytes": cmd.total_size_bytes,
481 "chunk_interval_ms": cmd.chunk_interval_ms,
482 "concurrency": cmd.concurrency,
483 "duration_secs": cmd.duration.as_secs(),
484 "result": {
485 "total_requests": r.total_requests,
486 "successful": r.successful,
487 "failed": r.failed,
488 "bytes_sent": r.bytes_sent,
489 "elapsed_ms": r.elapsed.as_millis(),
490 "req_per_sec": r.req_per_sec,
491 "avg_latency_ms": r.avg_latency_ms,
492 "p50_ms": r.p50_ms,
493 "p95_ms": r.p95_ms,
494 "p99_ms": r.p99_ms,
495 "status_counts": r.status_counts,
496 "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
497 "status": s.status,
498 "server_header": s.server_header,
499 "body_excerpt": s.body_excerpt,
500 })).collect::<Vec<_>>(),
501 },
502 })
503}
504
505fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
508 let mut out = String::new();
509 let mut line = |s: String| {
510 out.push_str(prefix);
511 out.push_str(&s);
512 out.push('\n');
513 };
514 line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
515 line(format!(" Total requests: {}", r.total_requests));
516 line(format!(" Successful: {}", r.successful));
517 line(format!(" Failed: {}", r.failed));
518 line(format!(" Bytes sent: {}", r.bytes_sent));
519 line(format!(" Throughput: {:.2} req/s", r.req_per_sec));
520 line(format!(
521 " Latency: avg={:.1}ms p50={}ms p95={}ms p99={}ms",
522 r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
523 ));
524 if !r.status_counts.is_empty() {
525 line(" Status codes:".to_string());
526 let mut codes: Vec<_> = r.status_counts.iter().collect();
527 codes.sort_by_key(|(k, _)| **k);
528 for (code, n) in codes {
529 line(format!(" {} = {}", code, n));
530 }
531 }
532 if !r.error_samples.is_empty() {
537 line(" Error response samples:".to_string());
538 for s in &r.error_samples {
539 let server = s.server_header.as_deref().unwrap_or("(no Server header)");
540 line(format!(" [{}] Server: {}", s.status, server));
541 if !s.body_excerpt.is_empty() {
542 line(format!(" body: {}", s.body_excerpt.replace('\n', " ")));
543 }
544 }
545 if r.status_counts.keys().any(|c| (500..600).contains(c)) {
546 line(
547 " Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
548 mean the proxy timed out reading from upstream. Each chunked request \
549 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
550 if that exceeds the proxy's upstream timeout, errors are inevitable."
551 .to_string(),
552 );
553 }
554 }
555 print!("{out}");
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn plan_target_runs_merges_headers_auth_spec_and_output() {
564 let mut target_headers = HashMap::new();
565 target_headers.insert("X-Env".to_string(), "b".to_string());
566 let targets = vec![
567 TargetConfig::from_url("10.0.0.1:8080".to_string()),
568 TargetConfig {
569 url: "https://b.example".to_string(),
570 auth: Some("Bearer t".to_string()),
571 headers: Some(target_headers),
572 spec: Some(PathBuf::from("b.yaml")),
573 },
574 ];
575 let mut cli_headers = HashMap::new();
576 cli_headers.insert("X-Env".to_string(), "cli".to_string());
577 let cli_spec = PathBuf::from("cli.yaml");
578
579 let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
580
581 assert_eq!(runs.len(), 2);
582 assert_eq!(runs[0].url, "http://10.0.0.1:8080");
583 assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
584 assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
585 assert_eq!(runs[0].output, Path::new("out").join("target_1"));
586 assert_eq!(runs[0].prefix, "[target_1] ");
587
588 assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
589 assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
590 assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
591 assert_eq!(runs[1].output, Path::new("out").join("target_2"));
592 }
593
594 fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
595 ChunkedCommand {
596 target: target.map(str::to_string),
597 targets_file,
598 max_concurrency: 10,
599 spec: None,
600 base_path: None,
601 operation_id: None,
602 method: "POST".to_string(),
603 concurrency: 1,
604 duration: Duration::from_millis(10),
605 chunk_size_bytes: 1024,
606 total_size_bytes: 4096,
607 chunk_interval_ms: 0,
608 headers: HashMap::new(),
609 insecure: false,
610 validate_requests: false,
611 export_requests: false,
612 output: PathBuf::from("bench-results"),
613 }
614 }
615
616 #[tokio::test]
617 async fn rejects_target_and_targets_file_together() {
618 let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
619 assert!(c.execute().await.is_err());
620 }
621
622 #[tokio::test]
623 async fn rejects_neither_target_nor_targets_file() {
624 assert!(cmd(None, None).execute().await.is_err());
625 }
626}