1use 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#[derive(Debug, Clone)]
35pub struct ChunkedCommand {
36 pub target: Option<String>,
38 pub targets_file: Option<PathBuf>,
40 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 pub rps: Option<u32>,
58 pub cps: bool,
60 pub rounds: Option<u32>,
62 pub repeat_until: Option<Duration>,
64 pub keep_rounds: Option<u32>,
66}
67
68#[derive(Debug, Clone)]
70struct TargetRun {
71 prefix: String,
73 url: String,
74 headers: HashMap<String, String>,
75 spec: Option<PathBuf>,
76 output: PathBuf,
77}
78
79#[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 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 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 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 "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 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 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 let mut violations: Vec<serde_json::Value> = Vec::new();
407 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
531fn 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
564fn 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
642fn 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 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}