Skip to main content

mockforge_bench/
chunked_command.rs

1//! `mockforge bench-chunked` driver: single-target, spec-driven, and
2//! multi-target (`--targets-file`) chunked-encoding runs.
3//!
4//! [`crate::chunked_bench::run`] drives one URL. This module layers the CLI
5//! modes on top of it:
6//!
7//! - **Single target** (`--target URL`, no `--spec`): one run against the URL.
8//! - **Spec-driven** (`--spec`): one run per POST/PUT/PATCH operation, with
9//!   `--target` as the base URL.
10//! - **Multi-target** (`--targets-file`, issue #79): every target in the file
11//!   runs the single-target or spec-driven flow in parallel (bounded by
12//!   `--max-concurrency`). It uses the same file format as
13//!   `mockforge bench --targets-file`, including per-target `auth`, `headers`
14//!   and `spec`. Per-target artifacts go to `<output>/target_<N>/`, and a
15//!   roll-up goes to `<output>/chunked-multi-target-summary.json`.
16//!
17//! Any mode can loop as a campaign (`--rounds` / `--repeat-until`, issue
18//! #79): each pass writes to `<output>/round_<N>/`, per-round stats go to
19//! `<output>/campaign.jsonl` + `<output>/round-summaries/`, and
20//! `--keep-rounds` prunes old `round_*` dirs, matching `mockforge bench`.
21
22use crate::chunked_bench::{
23    build_json_body, is_json_content_type, run, ChunkedBenchConfig, ChunkedBenchResult,
24};
25use crate::parallel_executor::ParallelExecutor;
26use crate::request_gen::RequestGenerator;
27use crate::spec_parser::SpecParser;
28use crate::target_parser::{parse_targets_file, TargetConfig};
29use anyhow::{bail, Context};
30use futures::StreamExt;
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36/// Options shared by every `bench-chunked` mode.
37#[derive(Debug, Clone)]
38pub struct ChunkedCommand {
39    /// Single target URL. Mutually exclusive with `targets_file`.
40    pub target: Option<String>,
41    /// File with multiple targets (same format as `bench --targets-file`).
42    pub targets_file: Option<PathBuf>,
43    /// Max targets running at once in multi-target mode.
44    pub max_concurrency: u32,
45    pub spec: Option<PathBuf>,
46    pub base_path: Option<String>,
47    pub operation_id: Option<String>,
48    pub method: String,
49    pub concurrency: u32,
50    pub duration: Duration,
51    pub chunk_size_bytes: usize,
52    pub total_size_bytes: usize,
53    pub chunk_interval_ms: u64,
54    pub headers: HashMap<String, String>,
55    pub insecure: bool,
56    pub validate_requests: bool,
57    pub export_requests: bool,
58    pub output: PathBuf,
59    /// Per-target cap on request starts per second (shared by its workers).
60    pub rps: Option<u32>,
61    /// New TCP/TLS connection per request, so connections/s = requests/s.
62    pub cps: bool,
63    /// Campaign: run the whole plan this many times.
64    pub rounds: Option<u32>,
65    /// Campaign: keep starting new rounds until this much wall clock elapsed.
66    pub repeat_until: Option<Duration>,
67    /// Campaign: keep only the newest N `round_*` dirs.
68    pub keep_rounds: Option<u32>,
69}
70
71/// One target's resolved run plan.
72#[derive(Debug, Clone)]
73struct TargetRun {
74    /// `""` in single-target mode, `"[target_N] "` in multi-target mode.
75    prefix: String,
76    url: String,
77    headers: HashMap<String, String>,
78    spec: Option<PathBuf>,
79    output: PathBuf,
80}
81
82/// Roll-up of one target's run(s).
83#[derive(Debug, Clone, Default)]
84struct TargetOutcome {
85    total_requests: u64,
86    successful: u64,
87    failed: u64,
88    bytes_sent: u64,
89    status_counts: HashMap<u16, u64>,
90    /// An operation errored out entirely (vs. individual requests failing).
91    run_failed: bool,
92}
93
94impl TargetOutcome {
95    fn absorb(&mut self, r: &ChunkedBenchResult) {
96        self.total_requests += r.total_requests;
97        self.successful += r.successful;
98        self.failed += r.failed;
99        self.bytes_sent += r.bytes_sent;
100        for (code, n) in &r.status_counts {
101            *self.status_counts.entry(*code).or_insert(0) += n;
102        }
103    }
104}
105
106impl ChunkedCommand {
107    /// Run the bench. Returns `Ok(true)` when every run completed, `Ok(false)`
108    /// when at least one operation or target failed to run.
109    pub async fn execute(&self) -> anyhow::Result<bool> {
110        match (&self.target, &self.targets_file) {
111            (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
112            (None, None) => bail!("either --target or --targets-file is required"),
113            _ => {}
114        }
115        if self.rounds == Some(0) {
116            bail!("--rounds must be >= 1 (or omit it for a single pass / --repeat-until)");
117        }
118        let max_rounds = self.rounds.unwrap_or(if self.repeat_until.is_some() {
119            u32::MAX
120        } else {
121            1
122        });
123        let looping = max_rounds > 1 || self.repeat_until.is_some();
124        if !looping {
125            if self.keep_rounds.is_some() {
126                eprintln!(
127                    "Note: --keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring"
128                );
129            }
130            return Ok(self.execute_pass(&self.output).await?.0);
131        }
132
133        match self.repeat_until {
134            Some(until) => println!(
135                "Campaign: re-running every pass until {}s of wall clock (or --rounds {})",
136                until.as_secs(),
137                self.rounds.map(|n| n.to_string()).unwrap_or_else(|| "unlimited".into())
138            ),
139            None => println!("Campaign: {max_rounds} round(s)"),
140        }
141        if let Some(keep) = self.keep_rounds {
142            println!(
143                "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
144            );
145        }
146
147        let campaign_start = std::time::Instant::now();
148        let mut all_ok = true;
149        let mut round: u32 = 0;
150        while round < max_rounds {
151            if let Some(until) = self.repeat_until {
152                if round > 0 && campaign_start.elapsed() >= until {
153                    println!("Reached --repeat-until; stopping after {round} round(s)");
154                    break;
155                }
156            }
157            round += 1;
158            let round_output = self.output.join(format!("round_{round}"));
159            println!(
160                "\n=== Round {round} (campaign elapsed {}s) → {} ===",
161                campaign_start.elapsed().as_secs(),
162                round_output.display()
163            );
164            let (ok, summary) = self.execute_pass(&round_output).await?;
165            all_ok &= ok;
166            write_round_record(&self.output, round, campaign_start.elapsed(), ok, summary)?;
167            if let Some(keep) = self.keep_rounds {
168                ParallelExecutor::prune_old_rounds(&self.output, keep);
169            }
170        }
171        Ok(all_ok)
172    }
173
174    /// One pass over the whole plan, writing artifacts under `output`.
175    /// Returns whether every run completed plus a JSON roll-up of the pass.
176    async fn execute_pass(&self, output: &Path) -> anyhow::Result<(bool, serde_json::Value)> {
177        match (&self.target, &self.targets_file) {
178            (Some(target), _) => {
179                let run = TargetRun {
180                    prefix: String::new(),
181                    url: target.clone(),
182                    headers: self.headers.clone(),
183                    spec: self.spec.clone(),
184                    output: output.to_path_buf(),
185                };
186                let o = self.run_target(&run).await?;
187                let summary = serde_json::json!({
188                    "url": target,
189                    "total_requests": o.total_requests,
190                    "successful": o.successful,
191                    "failed": o.failed,
192                    "bytes_sent": o.bytes_sent,
193                    "status_counts": o.status_counts,
194                    "run_failed": o.run_failed,
195                });
196                Ok((!o.run_failed, summary))
197            }
198            (None, Some(file)) => self.execute_multi_target(file, output).await,
199            (None, None) => bail!("either --target or --targets-file is required"),
200        }
201    }
202
203    async fn execute_multi_target(
204        &self,
205        file: &Path,
206        output: &Path,
207    ) -> anyhow::Result<(bool, serde_json::Value)> {
208        let targets = parse_targets_file(file)
209            .map_err(|e| anyhow::anyhow!("{e}"))
210            .with_context(|| format!("failed to read targets file {}", file.display()))?;
211        if targets.is_empty() {
212            bail!("no targets found in {}", file.display());
213        }
214        let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), output);
215        let max_concurrency = self.max_concurrency.max(1) as usize;
216        println!(
217            "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
218            runs.len(),
219            file,
220            max_concurrency
221        );
222
223        let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
224            futures::stream::iter(runs.into_iter().enumerate())
225                .map(|(i, run)| async move {
226                    let res = self.run_target(&run).await;
227                    (i, run, res)
228                })
229                .buffer_unordered(max_concurrency)
230                .collect()
231                .await;
232        outcomes.sort_by_key(|(i, _, _)| *i);
233
234        println!();
235        println!("Multi-target chunked summary:");
236        let mut all_ok = true;
237        let mut rows = Vec::with_capacity(outcomes.len());
238        for (i, run, res) in &outcomes {
239            let name = format!("target_{}", i + 1);
240            match res {
241                Ok(o) => {
242                    if o.run_failed {
243                        all_ok = false;
244                    }
245                    println!(
246                        "  {:<10} {}  requests={} ok={} failed={} bytes={}{}",
247                        name,
248                        run.url,
249                        o.total_requests,
250                        o.successful,
251                        o.failed,
252                        o.bytes_sent,
253                        if o.run_failed {
254                            "  (some operations failed to run)"
255                        } else {
256                            ""
257                        }
258                    );
259                    rows.push(serde_json::json!({
260                        "target": name,
261                        "url": run.url,
262                        "spec": run.spec.as_ref().map(|p| p.display().to_string()),
263                        // Per-target files (exports / violations) exist only in spec mode.
264                        "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
265                        "total_requests": o.total_requests,
266                        "successful": o.successful,
267                        "failed": o.failed,
268                        "bytes_sent": o.bytes_sent,
269                        "status_counts": o.status_counts,
270                        "run_failed": o.run_failed,
271                    }));
272                }
273                Err(e) => {
274                    all_ok = false;
275                    println!("  {:<10} {}  ERROR: {:#}", name, run.url, e);
276                    rows.push(serde_json::json!({
277                        "target": name,
278                        "url": run.url,
279                        "error": format!("{e:#}"),
280                    }));
281                }
282            }
283        }
284
285        std::fs::create_dir_all(output)
286            .with_context(|| format!("failed to create output directory {:?}", output))?;
287        let path = output.join("chunked-multi-target-summary.json");
288        let payload = serde_json::json!({
289            "targets_file": file.display().to_string(),
290            "concurrency_per_target": self.concurrency,
291            "max_concurrency": max_concurrency,
292            "duration_secs": self.duration.as_secs(),
293            "chunk_size_bytes": self.chunk_size_bytes,
294            "total_size_bytes": self.total_size_bytes,
295            "chunk_interval_ms": self.chunk_interval_ms,
296            "rps_per_target": self.rps,
297            "new_connection_per_request": self.cps,
298            "targets": rows,
299        });
300        std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
301            .with_context(|| format!("failed to write {:?}", path))?;
302        println!("📝 Wrote {:?}", path);
303        Ok((all_ok, payload))
304    }
305
306    fn bench_config(
307        &self,
308        url: String,
309        method: reqwest::Method,
310        headers: &HashMap<String, String>,
311        json_seed: Option<&serde_json::Value>,
312    ) -> ChunkedBenchConfig {
313        // A JSON Content-Type gets a real JSON document padded to
314        // --total-size-bytes; WAFs reject `X` filler as malformed JSON (#79).
315        let body = is_json_content_type(headers)
316            .then(|| Arc::new(build_json_body(json_seed, self.total_size_bytes)));
317        if let Some(b) = &body {
318            if b.len() > self.total_size_bytes {
319                eprintln!(
320                    "Note: --total-size-bytes {} is below the smallest valid JSON body; sending {} bytes",
321                    self.total_size_bytes,
322                    b.len()
323                );
324            }
325        }
326        ChunkedBenchConfig {
327            target_url: url,
328            method,
329            concurrency: self.concurrency,
330            duration: self.duration,
331            chunk_size_bytes: self.chunk_size_bytes,
332            total_size_bytes: self.total_size_bytes,
333            chunk_interval_ms: self.chunk_interval_ms,
334            headers: headers.clone(),
335            skip_tls_verify: self.insecure,
336            rps: self.rps,
337            no_keep_alive: self.cps,
338            body,
339        }
340    }
341
342    async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
343        match &t.spec {
344            Some(spec) => self.run_spec(t, spec).await,
345            None => self.run_single(t).await,
346        }
347    }
348
349    async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
350        let p = &t.prefix;
351        // Only warn once: in multi-target mode every target would repeat it.
352        if p.is_empty() {
353            if self.base_path.is_some() {
354                eprintln!(
355                    "Note: --base-path has no effect without --spec; \
356                     single-target mode uses --target verbatim"
357                );
358            }
359            if self.validate_requests {
360                eprintln!("Note: --validate-requests requires --spec (skipped)");
361            }
362            if self.export_requests {
363                eprintln!("Note: --export-requests requires --spec (skipped)");
364            }
365        }
366        let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
367            .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
368        if !p.is_empty() {
369            println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
370        }
371        let r = run(self.bench_config(t.url.clone(), method, &t.headers, None))
372            .await
373            .context("chunked bench failed")?;
374        print_result(
375            p,
376            if p.is_empty() {
377                "single-target"
378            } else {
379                &t.url
380            },
381            &r,
382        );
383        let mut outcome = TargetOutcome::default();
384        outcome.absorb(&r);
385        Ok(outcome)
386    }
387
388    async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
389        let p = &t.prefix;
390        let parser = SpecParser::from_file(spec_path)
391            .await
392            .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
393        let base_url = t.url.trim_end_matches('/').to_string();
394        // CLI --base-path > spec.servers > none. Empty string from CLI
395        // explicitly disables (matches `mockforge bench` semantics).
396        let effective_base_path: Option<String> = match &self.base_path {
397            Some(bp) if bp.is_empty() => None,
398            Some(bp) => Some(bp.clone()),
399            None => parser.get_base_path(),
400        };
401        let want_methods = ["POST", "PUT", "PATCH"];
402        let ops: Vec<_> = parser
403            .get_operations()
404            .into_iter()
405            .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
406            .filter(|op| match &self.operation_id {
407                Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
408                None => true,
409            })
410            .collect();
411        if ops.is_empty() {
412            bail!(
413                "No POST/PUT/PATCH operations matched in {:?}{}",
414                spec_path,
415                self.operation_id
416                    .as_deref()
417                    .map(|id| format!(" (operation-id={id})"))
418                    .unwrap_or_default()
419            );
420        }
421
422        // Pre-flight validation. Any failure here aborts before touching
423        // the network so the bench doesn't half-run.
424        let mut violations: Vec<serde_json::Value> = Vec::new();
425        // (label, method, url, op.path, generated request body)
426        let mut planned: Vec<(String, String, String, String, Option<serde_json::Value>)> =
427            Vec::with_capacity(ops.len());
428        for op in &ops {
429            let label = op.display_name();
430            match RequestGenerator::generate_template(op) {
431                Ok(template) => {
432                    let path_with_base = match &effective_base_path {
433                        Some(bp) if !bp.is_empty() => {
434                            format!("{}{}", bp, template.generate_path())
435                        }
436                        _ => template.generate_path().to_string(),
437                    };
438                    let url = format!("{}{}", base_url, path_with_base);
439                    if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
440                        violations.push(serde_json::json!({
441                            "operation": label,
442                            "method": op.method,
443                            "path": op.path,
444                            "kind": "invalid_method",
445                            "detail": format!("`{}` is not a valid HTTP method", op.method),
446                        }));
447                    } else {
448                        planned.push((
449                            label,
450                            op.method.to_uppercase(),
451                            url,
452                            op.path.clone(),
453                            template.body.clone(),
454                        ));
455                    }
456                }
457                Err(e) => {
458                    violations.push(serde_json::json!({
459                        "operation": label,
460                        "method": op.method,
461                        "path": op.path,
462                        "kind": "template_build_failure",
463                        "detail": e.to_string(),
464                    }));
465                }
466            }
467        }
468
469        if self.validate_requests {
470            if !violations.is_empty() {
471                std::fs::create_dir_all(&t.output)
472                    .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
473                let path = t.output.join("chunked-request-violations.json");
474                let payload = serde_json::json!({
475                    "spec": spec_path.display().to_string(),
476                    "base_url": base_url,
477                    "base_path": effective_base_path,
478                    "violations": violations,
479                });
480                std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
481                    .with_context(|| format!("Failed to write {:?}", path))?;
482                bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
483            }
484            println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
485        }
486
487        println!(
488            "{p}→ {} chunked bench {} from {:?} (base {}{})",
489            planned.len(),
490            if planned.len() == 1 {
491                "operation"
492            } else {
493                "operations"
494            },
495            spec_path,
496            base_url,
497            effective_base_path
498                .as_deref()
499                .map(|bp| format!(", base-path {}", bp))
500                .unwrap_or_default()
501        );
502
503        let mut outcome = TargetOutcome::default();
504        let mut export_records: Vec<serde_json::Value> = Vec::new();
505        for (label, method_str, url, op_path, json_seed) in &planned {
506            let method = match reqwest::Method::from_bytes(method_str.as_bytes()) {
507                Ok(m) => m,
508                Err(_) => {
509                    eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
510                    outcome.run_failed = true;
511                    continue;
512                }
513            };
514            println!();
515            println!("{p}→ {} {}  ({})", method_str, op_path, url);
516            let cfg = self.bench_config(url.clone(), method, &t.headers, json_seed.as_ref());
517            match run(cfg).await {
518                Ok(r) => {
519                    print_result(p, label, &r);
520                    outcome.absorb(&r);
521                    if self.export_requests {
522                        export_records.push(export_record(
523                            self, label, method_str, url, op_path, &t.headers, &r,
524                        ));
525                    }
526                }
527                Err(e) => {
528                    eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
529                    outcome.run_failed = true;
530                }
531            }
532        }
533
534        if self.export_requests {
535            std::fs::create_dir_all(&t.output)
536                .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
537            let path = t.output.join("chunked-requests.json");
538            let payload = serde_json::json!({
539                "spec": spec_path.display().to_string(),
540                "base_url": base_url,
541                "base_path": effective_base_path,
542                "operations": export_records,
543            });
544            std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
545                .with_context(|| format!("Failed to write {:?}", path))?;
546            println!(
547                "{p}📝 --export-requests: wrote {} operations to {:?}",
548                export_records.len(),
549                path
550            );
551        }
552
553        Ok(outcome)
554    }
555}
556
557/// Resolve each targets-file entry into a run plan: normalized URL, CLI
558/// headers overlaid with the target's `headers` and `auth`, the target's
559/// `spec` falling back to the CLI `--spec`, and `<output>/target_<N>/`.
560fn plan_target_runs(
561    targets: Vec<TargetConfig>,
562    cli_headers: &HashMap<String, String>,
563    cli_spec: Option<&PathBuf>,
564    output: &Path,
565) -> Vec<TargetRun> {
566    targets
567        .into_iter()
568        .enumerate()
569        .map(|(i, mut t)| {
570            t.normalize_url();
571            let mut headers = cli_headers.clone();
572            if let Some(h) = &t.headers {
573                headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
574            }
575            if let Some(auth) = &t.auth {
576                headers.insert("Authorization".to_string(), auth.clone());
577            }
578            let name = format!("target_{}", i + 1);
579            TargetRun {
580                prefix: format!("[{name}] "),
581                url: t.url,
582                headers,
583                spec: t.spec.or_else(|| cli_spec.cloned()),
584                output: output.join(name),
585            }
586        })
587        .collect()
588}
589
590/// Campaign bookkeeping for one finished round: a pruning-proof copy under
591/// `<output>/round-summaries/` and one line appended to
592/// `<output>/campaign.jsonl`.
593fn write_round_record(
594    output: &Path,
595    round: u32,
596    campaign_elapsed: Duration,
597    ok: bool,
598    pass: serde_json::Value,
599) -> anyhow::Result<()> {
600    use std::io::Write;
601    let record = serde_json::json!({
602        "round": round,
603        "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
604        "all_runs_completed": ok,
605        "pass": pass,
606    });
607    let summaries = output.join("round-summaries");
608    std::fs::create_dir_all(&summaries)
609        .with_context(|| format!("failed to create {:?}", summaries))?;
610    std::fs::write(
611        summaries.join(format!("round_{round}.json")),
612        serde_json::to_string_pretty(&record)?,
613    )?;
614    let mut line = serde_json::to_string(&record)?;
615    line.push('\n');
616    std::fs::OpenOptions::new()
617        .create(true)
618        .append(true)
619        .open(output.join("campaign.jsonl"))?
620        .write_all(line.as_bytes())
621        .context("failed to append campaign.jsonl")?;
622    Ok(())
623}
624
625fn export_record(
626    cmd: &ChunkedCommand,
627    label: &str,
628    method: &str,
629    url: &str,
630    op_path: &str,
631    headers: &HashMap<String, String>,
632    r: &ChunkedBenchResult,
633) -> serde_json::Value {
634    serde_json::json!({
635        "operation": label,
636        "method": method,
637        "url": url,
638        "spec_path": op_path,
639        "headers": headers,
640        "chunk_size_bytes": cmd.chunk_size_bytes,
641        "total_size_bytes": cmd.total_size_bytes,
642        "chunk_interval_ms": cmd.chunk_interval_ms,
643        "concurrency": cmd.concurrency,
644        "rps": cmd.rps,
645        "new_connection_per_request": cmd.cps,
646        "duration_secs": cmd.duration.as_secs(),
647        "result": {
648            "total_requests": r.total_requests,
649            "successful": r.successful,
650            "failed": r.failed,
651            "bytes_sent": r.bytes_sent,
652            "elapsed_ms": r.elapsed.as_millis(),
653            "req_per_sec": r.req_per_sec,
654            "avg_latency_ms": r.avg_latency_ms,
655            "p50_ms": r.p50_ms,
656            "p95_ms": r.p95_ms,
657            "p99_ms": r.p99_ms,
658            "status_counts": r.status_counts,
659            "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
660                "status": s.status,
661                "server_header": s.server_header,
662                "body_excerpt": s.body_excerpt,
663            })).collect::<Vec<_>>(),
664        },
665    })
666}
667
668/// Print one result block. The block is built first and printed with one
669/// call so parallel targets don't interleave their lines.
670fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
671    let mut out = String::new();
672    let mut line = |s: String| {
673        out.push_str(prefix);
674        out.push_str(&s);
675        out.push('\n');
676    };
677    line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
678    line(format!("  Total requests: {}", r.total_requests));
679    line(format!("  Successful:     {}", r.successful));
680    line(format!("  Failed:         {}", r.failed));
681    line(format!("  Bytes sent:     {}", r.bytes_sent));
682    line(format!("  Throughput:     {:.2} req/s", r.req_per_sec));
683    line(format!(
684        "  Latency:        avg={:.1}ms p50={}ms p95={}ms p99={}ms",
685        r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
686    ));
687    if !r.status_counts.is_empty() {
688        line("  Status codes:".to_string());
689        let mut codes: Vec<_> = r.status_counts.iter().collect();
690        codes.sort_by_key(|(k, _)| **k);
691        for (code, n) in codes {
692            line(format!("    {} = {}", code, n));
693        }
694    }
695    // Surface the captured error responses so the user can tell whether
696    // errors came from MockForge, an upstream proxy, a CDN, etc. Hint at the
697    // most common cause when 5xx is involved (proxy upstream timeout on long
698    // chunked uploads).
699    if !r.error_samples.is_empty() {
700        line("  Error response samples:".to_string());
701        for s in &r.error_samples {
702            let server = s.server_header.as_deref().unwrap_or("(no Server header)");
703            line(format!("    [{}] Server: {}", s.status, server));
704            if !s.body_excerpt.is_empty() {
705                line(format!("       body: {}", s.body_excerpt.replace('\n', " ")));
706            }
707        }
708        if r.status_counts.keys().any(|c| (500..600).contains(c)) {
709            line(
710                "  Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
711                 mean the proxy timed out reading from upstream. Each chunked request \
712                 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
713                 if that exceeds the proxy's upstream timeout, errors are inevitable."
714                    .to_string(),
715            );
716        }
717    }
718    print!("{out}");
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn plan_target_runs_merges_headers_auth_spec_and_output() {
727        let mut target_headers = HashMap::new();
728        target_headers.insert("X-Env".to_string(), "b".to_string());
729        let targets = vec![
730            TargetConfig::from_url("10.0.0.1:8080".to_string()),
731            TargetConfig {
732                url: "https://b.example".to_string(),
733                auth: Some("Bearer t".to_string()),
734                headers: Some(target_headers),
735                spec: Some(PathBuf::from("b.yaml")),
736            },
737        ];
738        let mut cli_headers = HashMap::new();
739        cli_headers.insert("X-Env".to_string(), "cli".to_string());
740        let cli_spec = PathBuf::from("cli.yaml");
741
742        let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
743
744        assert_eq!(runs.len(), 2);
745        assert_eq!(runs[0].url, "http://10.0.0.1:8080");
746        assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
747        assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
748        assert_eq!(runs[0].output, Path::new("out").join("target_1"));
749        assert_eq!(runs[0].prefix, "[target_1] ");
750
751        assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
752        assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
753        assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
754        assert_eq!(runs[1].output, Path::new("out").join("target_2"));
755    }
756
757    fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
758        ChunkedCommand {
759            target: target.map(str::to_string),
760            targets_file,
761            max_concurrency: 10,
762            spec: None,
763            base_path: None,
764            operation_id: None,
765            method: "POST".to_string(),
766            concurrency: 1,
767            duration: Duration::from_millis(10),
768            chunk_size_bytes: 1024,
769            total_size_bytes: 4096,
770            chunk_interval_ms: 0,
771            headers: HashMap::new(),
772            insecure: false,
773            validate_requests: false,
774            export_requests: false,
775            output: PathBuf::from("bench-results"),
776            rps: None,
777            cps: false,
778            rounds: None,
779            repeat_until: None,
780            keep_rounds: None,
781        }
782    }
783
784    #[tokio::test]
785    async fn rejects_target_and_targets_file_together() {
786        let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
787        assert!(c.execute().await.is_err());
788    }
789
790    #[tokio::test]
791    async fn rejects_neither_target_nor_targets_file() {
792        assert!(cmd(None, None).execute().await.is_err());
793    }
794
795    fn scratch_dir(name: &str) -> PathBuf {
796        let dir = std::env::temp_dir().join(format!(
797            "mf-chunked-{name}-{}-{}",
798            std::process::id(),
799            line!()
800        ));
801        let _ = std::fs::remove_dir_all(&dir);
802        std::fs::create_dir_all(&dir).unwrap();
803        dir
804    }
805
806    #[tokio::test]
807    async fn campaign_rounds_write_records_and_prune_round_dirs() {
808        let mut server = mockito::Server::new_async().await;
809        let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
810        let dir = scratch_dir("campaign");
811        let targets = dir.join("targets.txt");
812        std::fs::write(&targets, format!("{}/upload\n{}/upload\n", server.url(), server.url()))
813            .unwrap();
814        let mut c = cmd(None, Some(targets));
815        c.output = dir.join("out");
816        c.duration = Duration::from_millis(50);
817        c.rounds = Some(3);
818        c.keep_rounds = Some(1);
819
820        assert!(c.execute().await.unwrap());
821
822        let jsonl = std::fs::read_to_string(c.output.join("campaign.jsonl")).unwrap();
823        let rounds: Vec<serde_json::Value> =
824            jsonl.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
825        assert_eq!(rounds.len(), 3);
826        assert_eq!(rounds[2]["round"], 3);
827        assert_eq!(rounds[2]["pass"]["targets"].as_array().unwrap().len(), 2);
828        for n in 1..=3 {
829            assert!(c.output.join(format!("round-summaries/round_{n}.json")).exists());
830        }
831        assert!(!c.output.join("round_1").exists());
832        assert!(!c.output.join("round_2").exists());
833        assert!(c.output.join("round_3/chunked-multi-target-summary.json").exists());
834        let _ = std::fs::remove_dir_all(&dir);
835    }
836
837    #[tokio::test]
838    async fn campaign_repeat_until_stops_on_wall_clock() {
839        let mut server = mockito::Server::new_async().await;
840        let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
841        let dir = scratch_dir("until");
842        let mut c = cmd(Some(&format!("{}/upload", server.url())), None);
843        c.output = dir.clone();
844        // Short rounds against a roomy budget: a loaded CI runner can stretch
845        // one round's setup well past its nominal duration, so assert only that
846        // the campaign repeated and then stopped, not an exact count.
847        c.duration = Duration::from_millis(50);
848        c.repeat_until = Some(Duration::from_millis(500));
849
850        assert!(c.execute().await.unwrap());
851
852        let n = std::fs::read_to_string(dir.join("campaign.jsonl")).unwrap().lines().count();
853        assert!((2..=11).contains(&n), "expected 2-11 rounds of 50ms in 500ms, got {n}");
854        let _ = std::fs::remove_dir_all(&dir);
855    }
856
857    #[tokio::test]
858    async fn rejects_zero_rounds() {
859        let mut c = cmd(Some("http://127.0.0.1:1"), None);
860        c.rounds = Some(0);
861        assert!(c.execute().await.is_err());
862    }
863}