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