• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 30248040547

27 Jul 2026 07:57AM UTC coverage: 80.574% (+6.4%) from 74.202%
30248040547

Pull #81

github

web-flow
Merge 55b8756ce into 67c08fb25
Pull Request #81: rhai

47513 of 58968 relevant lines covered (80.57%)

23229.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.86
/src/cli/args.rs
1
use clap::builder::styling::{AnsiColor, Styles};
2
use clap::{Args, Parser, Subcommand};
3
use std::path::PathBuf;
4

5
/// Help styling that matches the console design language: bold-green section
6
/// headers, bold-cyan flags/commands, plain placeholders. anstream strips these
7
/// automatically when output is piped or `NO_COLOR` is set.
8
const HELP_STYLES: Styles = Styles::styled()
9
    .header(AnsiColor::Green.on_default().bold())
10
    .usage(AnsiColor::Green.on_default().bold())
11
    .literal(AnsiColor::Cyan.on_default().bold())
12
    .placeholder(AnsiColor::White.on_default());
13

14
/// Progress indicator modes
15
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
pub enum ProgressMode {
17
    Dots,
18
    None,
19
    Verbose,
20
}
21

22
impl std::str::FromStr for ProgressMode {
23
    type Err = ();
24

25
    fn from_str(s: &str) -> Result<Self, Self::Err> {
×
26
        match s {
×
27
            "dots" => Ok(Self::Dots),
×
28
            "bar" => Ok(Self::Dots),
×
29
            "none" => Ok(Self::None),
×
30
            _ => Ok(Self::Dots),
×
31
        }
32
    }
×
33
}
34

35
/// Log format types
36
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37
pub enum LogFormat {
38
    Console,
39
    Json,
40
    Yaml,
41
    JUnit,
42
    Allure,
43
    Html,
44
}
45

46
/// gRPC testing utility written in Rust
47
#[derive(Parser, Debug)]
48
#[command(name = "grpctestify")]
49
#[command(author = "grpctestify team")]
50
#[command(version = env!("CARGO_PKG_VERSION"))]
51
#[command(styles = HELP_STYLES)]
52
// The snail logo is attached at runtime in `main.rs` so it renders through the
53
// same colour-aware path as the welcome screen (green on TTY, plain otherwise).
54
#[command(
55
    about = "Native, zero-dependency gRPC testing with .gctf files",
56
    long_about = "Native, zero-dependency gRPC testing with .gctf files.\n\n\
57
        Run declarative .gctf tests against gRPC / gRPC-Web / Connect endpoints,\n\
58
        benchmark them, scaffold new tests from reflection, and explore APIs in a\n\
59
        web playground — all from a single self-contained binary.\n\n\
60
        Run `grpctestify` with no arguments for a quick tour.",
61
    after_help = "Examples:\n  \
62
        grpctestify tests/\n  \
63
        grpctestify tests/ --parallel 8 -v\n  \
64
        grpctestify reflect --address localhost:4770 --plaintext\n  \
65
        grpctestify scaffold --endpoint pkg.Svc/Method --reflect\n\n\
66
        Docs: https://gripmock.github.io/grpctestify-rust/"
67
)]
68
pub struct Cli {
69
    #[command(subcommand)]
70
    pub command: Option<Commands>,
71

72
    // Flatten RunArgs to support implicit run command at top-level.
73
    // This allows `grpctestify tests/` to work as expected.
74
    #[command(flatten)]
75
    pub run_args: RunArgs,
76

77
    /// Enable verbose debug output
78
    #[arg(short = 'v', long, global = true, default_value_t = false)]
79
    pub verbose: bool,
80

81
    /// Optimizer level (0=none, 1=safe, 2=advisory, 3=aggressive)
82
    #[arg(long = "optimize", short = 'O', value_name = "LEVEL", global = true, default_value_t = String::new())]
83
    pub optimize: String,
84

85
    /// Install shell completion (bash, zsh, fish, elvish, powershell)
86
    #[arg(long, value_name = "SHELL_TYPE", value_parser = ["bash", "zsh", "fish", "elvish", "powershell"])]
87
    pub completion: Option<String>,
88
}
89

90
#[allow(clippy::large_enum_variant)]
91
#[derive(Subcommand, Debug)]
92
pub enum Commands {
93
    // Authoring loop — write, validate, format.
94
    /// Run .gctf tests (default)
95
    Run(Box<RunArgs>),
96
    /// Validate .gctf syntax & semantics
97
    Check(CheckArgs),
98
    /// Format .gctf files in place
99
    Fmt(FmtArgs),
100

101
    // Create tests from an existing API.
102
    /// Generate a .gctf from proto/descriptor/reflection
103
    Scaffold(ScaffoldArgs),
104
    /// Generate a .gctf from a captured invocation
105
    Gen(GenArgs),
106

107
    // Explore & understand.
108
    /// List services & methods via server reflection
109
    Reflect(ReflectArgs),
110
    /// Show a test's structure & metadata
111
    Inspect(InspectArgs),
112
    /// Explain a test's execution flow
113
    Explain(ExplainArgs),
114
    /// Print the equivalent grpcurl command
115
    Grpcurl(GrpcurlArgs),
116
    /// List discovered .gctf test files
117
    List(ListArgs),
118
    /// Generate Markdown API docs from .gctf test files
119
    Docs(DocsArgs),
120
    /// Visualize fixture setup/teardown topology and multi-document chains
121
    Graph(GraphArgs),
122

123
    // Probe a running server.
124
    /// Call a gRPC method without assertions
125
    Call(CallArgs),
126
    /// Check a server's gRPC health
127
    Health(HealthArgs),
128

129
    // Performance.
130
    /// Load-test endpoints
131
    Bench(BenchArgs),
132
    /// Compare two bench reports & gate regressions
133
    BenchCompare(BenchCompareArgs),
134

135
    // Data sources.
136
    /// Build & manage data-source indexes
137
    Index(IndexArgs),
138
    /// Query a data source (CSV/TSV/NDJSON)
139
    Query(QueryArgs),
140

141
    // Servers & tooling.
142
    /// Launch the web playground
143
    Play(PlayArgs),
144
    /// Run the .gctf language server (LSP)
145
    Lsp(LspArgs),
146

147
    /// Install/manage .rhai plugins from a git host
148
    Plugins(PluginsArgs),
149
}
150

151
#[derive(Args, Debug, Clone)]
152
pub struct PluginsArgs {
153
    #[command(subcommand)]
154
    pub action: PluginsAction,
155
}
156

157
#[derive(Subcommand, Debug, Clone)]
158
pub enum PluginsAction {
159
    /// Install a plugin from host/owner/repo[/subpath][@spec]
160
    Install(PluginsInstallArgs),
161
    /// List installed plugins
162
    List(PluginsListArgs),
163
    /// Remove an installed plugin
164
    Remove(PluginsRemoveArgs),
165
    /// Re-resolve and re-fetch installed plugins
166
    Update(PluginsUpdateArgs),
167
}
168

169
#[derive(Args, Debug, Clone)]
170
pub struct PluginsInstallArgs {
171
    /// host/owner/repo[/subpath][@spec] — spec is a tag/branch (exact) or
172
    /// a semver range like ^1.2.0 (omit for the highest semver tag, or HEAD
173
    /// if the repo has none)
174
    pub source: String,
175

176
    /// Install to $HOME/.grpctestify instead of the project-local
177
    /// ./.grpctestify
178
    #[arg(short = 'g', long, default_value_t = false)]
179
    pub global: bool,
180
}
181

182
#[derive(Args, Debug, Clone)]
183
pub struct PluginsListArgs {
184
    #[arg(short = 'g', long, default_value_t = false)]
185
    pub global: bool,
186

187
    /// List both the project-local and user-global tiers
188
    #[arg(long, default_value_t = false)]
189
    pub all: bool,
190
}
191

192
#[derive(Args, Debug, Clone)]
193
pub struct PluginsRemoveArgs {
194
    /// host/owner/repo, as shown by `plugins list`
195
    pub name: String,
196

197
    #[arg(short = 'g', long, default_value_t = false)]
198
    pub global: bool,
199
}
200

201
#[derive(Args, Debug, Clone)]
202
pub struct PluginsUpdateArgs {
203
    /// host/owner/repo to update (omit to update every installed plugin in the tier)
204
    pub name: Option<String>,
205

206
    #[arg(short = 'g', long, default_value_t = false)]
207
    pub global: bool,
208
}
209

210
#[derive(Args, Debug, Clone)]
211
pub struct ScaffoldArgs {
212
    /// Fully-qualified method to scaffold (package.Service/Method)
213
    #[arg(long, value_name = "SERVICE/METHOD", required = true)]
214
    pub endpoint: String,
215

216
    /// Proto file or directory to compile (pure-Rust protox, no protoc)
217
    #[arg(long, value_name = "FILE_OR_DIR")]
218
    pub proto: Option<PathBuf>,
219

220
    /// Pre-compiled FileDescriptorSet (.protoset/.pb)
221
    #[arg(long, value_name = "FILE")]
222
    pub descriptor: Option<PathBuf>,
223

224
    /// Load descriptors from server reflection at --address
225
    #[arg(long, default_value_t = false)]
226
    pub reflect: bool,
227

228
    /// Server address (host:port)
229
    #[arg(long, value_name = "ADDRESS")]
230
    pub address: Option<String>,
231

232
    /// Output file (stdout if omitted)
233
    #[arg(short = 'o', long, value_name = "FILE")]
234
    pub output: Option<PathBuf>,
235

236
    /// Overwrite the output file if it already exists
237
    #[arg(long, default_value_t = false)]
238
    pub force: bool,
239

240
    /// Use TLS with certificate verification
241
    #[arg(long, default_value_t = false)]
242
    pub tls: bool,
243

244
    /// Skip TLS certificate verification
245
    #[arg(long, default_value_t = false)]
246
    pub insecure: bool,
247

248
    /// Plaintext connection (no TLS)
249
    #[arg(long, default_value_t = false)]
250
    pub plaintext: bool,
251

252
    /// Wire protocol: grpc, grpc-web, connectrpc
253
    #[arg(long, default_value = "grpc", value_name = "PROTOCOL")]
254
    pub protocol: String,
255
}
256

257
#[derive(Args, Debug, Clone)]
258
pub struct HealthArgs {
259
    /// Server address (host:port)
260
    #[arg(required = true, value_name = "ADDRESS")]
261
    pub address: String,
262

263
    /// Wire protocol: grpc, grpc-web, connectrpc
264
    #[arg(long, default_value = "grpc", value_name = "PROTOCOL")]
265
    pub protocol: String,
266

267
    /// Service name to check (repeatable; default: none — checks overall server health)
268
    #[arg(long, value_name = "NAME")]
269
    pub service: Vec<String>,
270

271
    /// Output format: text, json
272
    #[arg(long, default_value = "text", value_name = "FORMAT")]
273
    pub format: String,
274

275
    /// Use TLS with certificate verification
276
    #[arg(long, default_value_t = false)]
277
    pub tls: bool,
278

279
    /// Skip TLS certificate verification
280
    #[arg(long, default_value_t = false)]
281
    pub insecure: bool,
282

283
    /// Timeout in seconds (per health-check RPC)
284
    #[arg(long, default_value_t = 10, value_name = "SECS")]
285
    pub timeout: u64,
286

287
    /// Poll until healthy instead of checking once (for CI readiness gating)
288
    #[arg(long, default_value_t = false)]
289
    pub watch: bool,
290

291
    /// Poll interval in seconds for --watch
292
    #[arg(long, default_value_t = 1.0, value_name = "SECS")]
293
    pub interval: f64,
294

295
    /// Give up waiting after this many seconds in --watch mode
296
    #[arg(long, default_value_t = 60.0, value_name = "SECS")]
297
    pub watch_timeout: f64,
298
}
299

300
#[derive(Args, Debug, Clone)]
301
pub struct GrpcurlArgs {
302
    /// File to convert
303
    #[arg(required = true, value_name = "FILE")]
304
    pub file: PathBuf,
305

306
    /// Document index for multi-document .gctf files (1-based)
307
    #[arg(long)]
308
    pub doc_index: Option<usize>,
309

310
    /// Output format: text, json
311
    #[arg(long, default_value = "text", value_name = "FORMAT")]
312
    pub format: String,
313
}
314

315
#[derive(Args, Debug, Clone)]
316
pub struct LspArgs {
317
    /// Use stdio for communication (default)
318
    #[arg(long, default_value_t = true)]
319
    pub stdio: bool,
320
}
321

322
#[derive(Args, Debug, Clone)]
323
pub struct BenchArgs {
324
    /// Wire protocol: grpc, grpc-web, connectrpc
325
    #[arg(long, default_value = "grpc", value_name = "PROTOCOL")]
326
    pub protocol: String,
327

328
    /// Test files or directories to benchmark
329
    #[arg(required = true, value_name = "PATH")]
330
    pub test_paths: Vec<PathBuf>,
331

332
    /// Benchmark profile (functional, load, stress, spike, soak)
333
    #[arg(long, value_name = "PROFILE")]
334
    pub profile: Option<String>,
335

336
    /// Benchmark mode (fixed, stepping, adaptive)
337
    #[arg(long, value_name = "MODE")]
338
    pub mode: Option<String>,
339

340
    /// Number of concurrent workers
341
    #[arg(short = 'c', long, value_name = "N")]
342
    pub concurrency: Option<u32>,
343

344
    /// Total number of requests to send
345
    #[arg(short = 'n', long, value_name = "N")]
346
    pub requests: Option<u64>,
347

348
    /// Duration of benchmark (e.g., 30s, 5m)
349
    #[arg(short = 'd', long, value_name = "DURATION")]
350
    pub duration: Option<String>,
351

352
    /// Ramp-up duration before steady-state load (e.g., 10s)
353
    #[arg(long = "ramp-up", alias = "ramp_up", value_name = "DURATION")]
354
    pub ramp_up: Option<String>,
355

356
    /// Warmup period excluded from final metrics (e.g., 5s)
357
    #[arg(long, value_name = "DURATION")]
358
    pub warmup: Option<String>,
359

360
    /// Maximum runtime with request-count mode (e.g., 30s, 5m)
361
    #[arg(long, value_name = "DURATION")]
362
    pub max_duration: Option<String>,
363

364
    /// Maximum requests per second (rate limit)
365
    #[arg(long, value_name = "RPS")]
366
    pub max_rps: Option<f64>,
367

368
    /// Load schedule strategy (const, step, line)
369
    #[arg(long = "load-schedule", value_name = "SCHEDULE")]
370
    pub load_schedule: Option<String>,
371

372
    /// Starting RPS for step/line load schedules
373
    #[arg(long = "load-start", value_name = "RPS")]
374
    pub load_start: Option<f64>,
375

376
    /// Step/slope RPS delta for step/line schedules
377
    #[arg(long = "load-step", value_name = "RPS_DELTA")]
378
    pub load_step: Option<f64>,
379

380
    /// Optional ending RPS for step/line schedules
381
    #[arg(long = "load-end", value_name = "RPS")]
382
    pub load_end: Option<f64>,
383

384
    /// Duration of each step for step schedule
385
    #[arg(long = "load-step-duration", value_name = "DURATION")]
386
    pub load_step_duration: Option<String>,
387

388
    /// Maximum duration of load adjustments
389
    #[arg(long = "load-max-duration", value_name = "DURATION")]
390
    pub load_max_duration: Option<String>,
391

392
    /// Number of gRPC connections to use (<= concurrency)
393
    #[arg(long, value_name = "N")]
394
    pub connections: Option<u32>,
395

396
    /// Connection timeout (e.g., 10s)
397
    #[arg(long, value_name = "DURATION")]
398
    pub connect_timeout: Option<String>,
399

400
    /// Keepalive interval (e.g., 30s)
401
    #[arg(long, value_name = "DURATION")]
402
    pub keepalive: Option<String>,
403

404
    /// Number of CPU cores to use
405
    #[arg(long, value_name = "N")]
406
    pub cpus: Option<usize>,
407

408
    /// User-defined benchmark run name
409
    #[arg(long, value_name = "NAME")]
410
    pub name: Option<String>,
411

412
    /// Assertion mode (fail_fast, collect_all, skip)
413
    #[arg(long, visible_alias = "bench-assert-mode", value_name = "MODE")]
414
    pub assert_mode: Option<String>,
415

416
    /// Disable ASSERTS evaluation to measure transport baseline
417
    #[arg(long, visible_alias = "bench-no-assert", default_value_t = false)]
418
    pub no_assert: bool,
419

420
    /// Sample rate for detailed logging (0.0-1.0)
421
    #[arg(long, value_name = "RATE")]
422
    pub sample_rate: Option<f64>,
423

424
    /// Enable reflection/proto caching
425
    #[arg(long)]
426
    pub cache: Option<bool>,
427

428
    /// Skip first N requests in latency metrics
429
    #[arg(long, value_name = "N")]
430
    pub skip_first: Option<u32>,
431

432
    /// Include errors in latency calculation
433
    #[arg(long)]
434
    pub count_errors_in_latency: Option<bool>,
435

436
    /// In-flight handling when duration limit is reached (close, wait, ignore)
437
    #[arg(long, value_name = "MODE")]
438
    pub duration_stop: Option<String>,
439

440
    /// Latency percentiles to report (comma-separated, e.g. p50,p90,p95,p99)
441
    #[arg(long, value_name = "LIST")]
442
    pub latency_percentiles: Option<String>,
443

444
    /// Progress heartbeat interval (e.g. 5s)
445
    #[arg(long = "progress-interval", value_name = "DURATION")]
446
    pub progress_interval: Option<String>,
447

448
    /// Report format: console, json, csv, ndjson, prometheus
449
    #[arg(
450
        long = "log-format",
451
        visible_alias = "bench-format",
452
        default_value = "console",
453
        value_name = "FORMAT"
454
    )]
455
    pub format: String,
456

457
    /// Output file for the report
458
    #[arg(
459
        short = 'o',
460
        long = "log-output",
461
        visible_alias = "bench-output",
462
        value_name = "FILE"
463
    )]
464
    pub output: Option<PathBuf>,
465

466
    /// Custom MiniJinja template file for benchmark report
467
    #[arg(long, value_name = "TEMPLATE_FILE")]
468
    pub report_template: Option<PathBuf>,
469

470
    /// Allure output directory for benchmark attachments
471
    #[arg(long, value_name = "DIR")]
472
    pub allure_output_dir: Option<PathBuf>,
473

474
    /// Compact console output (omit histogram)
475
    #[arg(long, default_value_t = false)]
476
    pub compact: bool,
477

478
    /// Only run tests carrying ALL of these tags (repeatable)
479
    #[arg(long = "tags", value_name = "TAG")]
480
    pub tags: Vec<String>,
481

482
    /// Skip tests carrying ANY of these tags (repeatable)
483
    #[arg(long = "skip-tags", value_name = "TAG")]
484
    pub skip_tags: Vec<String>,
485

486
    /// Exclude paths matching this glob (repeatable)
487
    #[arg(long, value_name = "GLOB")]
488
    pub exclude: Vec<String>,
489

490
    /// List available benchmark profiles and exit
491
    #[arg(long, default_value_t = false)]
492
    pub list_profiles: bool,
493

494
    /// Path to custom profile YAML file
495
    #[arg(long, value_name = "FILE")]
496
    pub profile_file: Option<PathBuf>,
497

498
    /// Direct gRPC method call (service/method) — no .gctf file needed
499
    #[arg(long, value_name = "SERVICE/METHOD")]
500
    pub call: Option<String>,
501

502
    /// Inline JSON request body (used with --call)
503
    #[arg(long, value_name = "JSON")]
504
    pub data: Option<String>,
505
}
506

507
#[derive(Args, Debug, Clone)]
508
pub struct BenchCompareArgs {
509
    /// Baseline bench report (JSON produced by `bench --log-format json`)
510
    #[arg(required = true, value_name = "FILE")]
511
    pub baseline: PathBuf,
512

513
    /// Current bench report to compare against the baseline
514
    #[arg(required = true, value_name = "FILE")]
515
    pub current: PathBuf,
516

517
    /// Max tolerated latency rise (percent) for mean and each percentile
518
    #[arg(long, value_name = "PCT", default_value_t = 10.0)]
519
    pub max_latency_regression: f64,
520

521
    /// Max tolerated error-rate rise, in percentage points
522
    #[arg(long, value_name = "POINTS", default_value_t = 1.0)]
523
    pub max_error_rate_regression: f64,
524

525
    /// Max tolerated throughput (rps) drop (percent) before failing
526
    #[arg(long, value_name = "PCT", default_value_t = 5.0)]
527
    pub min_throughput: f64,
528

529
    /// Report format: console, json
530
    #[arg(long, default_value = "console", value_name = "FORMAT")]
531
    pub format: String,
532
}
533

534
#[derive(Args, Debug, Clone)]
535
pub struct IndexArgs {
536
    /// .gctf file(s) or directory with BENCH.sources definitions
537
    #[arg(required = true, value_name = "PATH")]
538
    pub sources: Vec<PathBuf>,
539

540
    /// Force rebuild of all required indexes
541
    #[arg(long, default_value_t = false)]
542
    pub force: bool,
543

544
    /// Show index file statistics
545
    #[arg(long, default_value_t = false)]
546
    pub stats: bool,
547
}
548

549
#[derive(Args, Debug, Clone)]
550
pub struct QueryArgs {
551
    /// Files or directories to query (default: interactive shell)
552
    #[arg(required = false, value_name = "PATH")]
553
    pub files: Vec<PathBuf>,
554

555
    /// Query expression to execute
556
    #[arg(short = 'q', long, value_name = "EXPR")]
557
    pub query: Option<String>,
558

559
    /// Run in interactive shell mode
560
    #[arg(short = 's', long, default_value_t = false)]
561
    pub shell: bool,
562

563
    /// Index column for direct file mode
564
    #[arg(short = 'i', long, value_name = "COLUMN")]
565
    pub indexed_by: Option<String>,
566

567
    /// Output format: json, csv, table, line, tsv
568
    #[arg(short = 'f', long, default_value = "table", value_name = "FORMAT")]
569
    pub format: String,
570

571
    /// Maximum number of rows to return
572
    #[arg(short = 'n', long, value_name = "N")]
573
    pub limit: Option<usize>,
574

575
    /// Skip N rows
576
    #[arg(short = 'o', long, value_name = "N")]
577
    pub offset: Option<usize>,
578

579
    /// Output columns (comma-separated)
580
    #[arg(short = 'c', long, value_name = "COLS")]
581
    pub columns: Option<String>,
582

583
    /// Sort by column (prefix with - for DESC)
584
    #[arg(long, value_name = "COLUMN")]
585
    pub order_by: Option<String>,
586

587
    /// Output file (stdout if omitted)
588
    #[arg(long, value_name = "FILE")]
589
    pub output: Option<PathBuf>,
590

591
    /// Skip header row in output
592
    #[arg(long, default_value_t = false)]
593
    pub no_header: bool,
594
}
595

596
#[derive(Args, Debug, Clone)]
597
pub struct ListArgs {
598
    /// Test file or directory to list
599
    #[arg(required = false, value_name = "PATH")]
600
    pub path: Option<PathBuf>,
601

602
    /// Output format: text, json
603
    #[arg(long, default_value = "json", value_name = "FORMAT")]
604
    pub format: String,
605

606
    /// Include test range information
607
    #[arg(long, default_value_t = false)]
608
    pub with_range: bool,
609
}
610

611
#[derive(Args, Debug, Clone)]
612
pub struct DocsArgs {
613
    /// Test files or directories to document (defaults to the current directory)
614
    #[arg(value_name = "PATH")]
615
    pub paths: Vec<PathBuf>,
616

617
    /// Directory to write the generated Markdown into
618
    #[arg(long, short = 'o', default_value = "docs/api", value_name = "DIR")]
619
    pub output: PathBuf,
620

621
    /// Embed method/field coverage from a prior `run --coverage --coverage-format json` report
622
    #[arg(long, value_name = "PATH")]
623
    pub coverage: Option<PathBuf>,
624
}
625

626
#[derive(Args, Debug, Clone)]
627
pub struct GraphArgs {
628
    /// Test files or directories to visualize (defaults to the current directory)
629
    #[arg(value_name = "PATH")]
630
    pub paths: Vec<PathBuf>,
631

632
    /// Output format: text, mermaid
633
    #[arg(long, default_value = "text", value_name = "FORMAT")]
634
    pub format: String,
635
}
636

637
#[derive(Args, Debug, Clone)]
638
pub struct InspectArgs {
639
    /// File to inspect
640
    #[arg(required = true, value_name = "FILE")]
641
    pub file: PathBuf,
642

643
    /// Output format: text, json
644
    #[arg(long, default_value = "text", value_name = "FORMAT")]
645
    pub format: String,
646
}
647

648
#[derive(Args, Debug, Clone)]
649
pub struct ExplainArgs {
650
    /// File to explain
651
    #[arg(required = true, value_name = "FILE")]
652
    pub file: PathBuf,
653

654
    /// Output format: text, json
655
    #[arg(long, default_value = "text", value_name = "FORMAT")]
656
    pub format: String,
657

658
    /// Post-hoc mode: JSON report from a prior `run --log-format json` to
659
    /// correlate against this file's plan (actual per-assertion pass/fail +
660
    /// timing, instead of just the static/optimized plan).
661
    #[arg(long, value_name = "REPORT_JSON")]
662
    pub against: Option<PathBuf>,
663
}
664

665
#[derive(Args, Debug, Clone)]
666
pub struct CheckArgs {
667
    /// Files to validate
668
    #[arg(required = true, value_name = "FILES")]
669
    pub files: Vec<PathBuf>,
670

671
    /// Output format: text, json
672
    #[arg(long, default_value = "text", value_name = "FORMAT")]
673
    pub format: String,
674

675
    /// Validate BENCH section configuration
676
    #[arg(long, default_value_t = false)]
677
    pub bench: bool,
678
}
679

680
#[derive(Args, Debug, Clone)]
681
pub struct RunArgs {
682
    /// Test files or directories to run (defaults to the current directory)
683
    // Optional so it doesn't clash with subcommand names when parsed at the top
684
    // level; emptiness is enforced manually where a path is required.
685
    #[arg(required = false, value_name = "PATH")]
686
    pub test_paths: Vec<PathBuf>,
687

688
    /// Exclude paths matching this glob (repeatable)
689
    #[arg(long = "exclude", value_name = "GLOB", help_heading = "Test Selection")]
690
    pub exclude: Vec<String>,
691

692
    /// Only run tests carrying ALL of these tags (repeatable)
693
    #[arg(long = "tags", value_name = "TAG", help_heading = "Test Selection")]
694
    pub tags: Vec<String>,
695

696
    /// Skip tests carrying ANY of these tags (repeatable)
697
    #[arg(
698
        long = "skip-tags",
699
        value_name = "TAG",
700
        help_heading = "Test Selection"
701
    )]
702
    pub skip_tags: Vec<String>,
703

704
    /// Order tests before running: path, size, name
705
    #[arg(
706
        short = 's',
707
        long,
708
        default_value = "path",
709
        value_name = "MODE",
710
        help_heading = "Test Selection"
711
    )]
712
    pub sort: String,
713

714
    /// Data source (CSV/TSV/NDJSON) driving each .gctf as a template — one case
715
    /// per row (`{{source.column}}` substitution)
716
    #[arg(long, value_name = "PATH", help_heading = "Test Selection")]
717
    pub data: Option<PathBuf>,
718

719
    /// Override the --data format (csv, tsv, ndjson); inferred from the extension otherwise
720
    #[arg(
721
        long,
722
        value_name = "FORMAT",
723
        requires = "data",
724
        help_heading = "Test Selection"
725
    )]
726
    pub data_format: Option<String>,
727

728
    /// Only run tests whose file content differs from --since (git, no shell-out)
729
    #[arg(long, default_value_t = false, help_heading = "Test Selection")]
730
    pub only_changed: bool,
731

732
    /// Git ref --only-changed compares against (default: HEAD, i.e. uncommitted changes)
733
    #[arg(
734
        long,
735
        default_value = "HEAD",
736
        value_name = "REF",
737
        requires = "only_changed",
738
        help_heading = "Test Selection"
739
    )]
740
    pub since: String,
741

742
    /// Wire protocol: grpc, grpc-web, connectrpc
743
    #[arg(
744
        long,
745
        default_value = "grpc",
746
        value_name = "PROTOCOL",
747
        help_heading = "Execution"
748
    )]
749
    pub protocol: String,
750

751
    /// Worker count for parallel runs, or `auto`
752
    #[arg(
753
        short = 'p',
754
        long,
755
        default_value = "auto",
756
        value_name = "N",
757
        help_heading = "Execution"
758
    )]
759
    pub parallel: String,
760

761
    /// Per-test timeout in seconds
762
    #[arg(
763
        short = 't',
764
        long,
765
        default_value_t = 30,
766
        value_name = "SECS",
767
        help_heading = "Execution"
768
    )]
769
    pub timeout: u64,
770

771
    /// Retry failed network calls up to N times
772
    #[arg(
773
        short = 'r',
774
        long,
775
        default_value_t = 0,
776
        value_name = "N",
777
        help_heading = "Execution"
778
    )]
779
    pub retry: u32,
780

781
    /// Initial delay between retries in seconds
782
    #[arg(
783
        long,
784
        default_value_t = 1.0,
785
        value_name = "SECS",
786
        help_heading = "Execution"
787
    )]
788
    pub retry_delay: f64,
789

790
    /// Disable retries entirely
791
    #[arg(long, default_value_t = false, help_heading = "Execution")]
792
    pub no_retry: bool,
793

794
    /// Skip assertions and print raw server responses
795
    #[arg(long, default_value_t = false, help_heading = "Execution")]
796
    pub no_assert: bool,
797

798
    /// Snapshot mode: write actual server responses back into the test files
799
    #[arg(short = 'w', long, default_value_t = false, help_heading = "Execution")]
800
    pub write: bool,
801

802
    /// Show what would run without executing anything
803
    #[arg(short = 'd', long, default_value_t = false, help_heading = "Execution")]
804
    pub dry_run: bool,
805

806
    /// Report format: junit, json, yaml, allure, html (comma-separated for several, e.g. junit,html)
807
    #[arg(long, value_name = "FORMAT", help_heading = "Output & Reports")]
808
    pub log_format: Option<String>,
809

810
    /// Destination for --log-format: exact file (or directory, for allure) for
811
    /// one format; a directory holding one file per format for several
812
    #[arg(long, value_name = "PATH", help_heading = "Output & Reports")]
813
    pub log_output: Option<PathBuf>,
814

815
    /// Emit streaming NDJSON events (for IDE/CI integration)
816
    #[arg(long, default_value_t = false, help_heading = "Output & Reports")]
817
    pub stream: bool,
818

819
    /// Progress style: auto, dots, verbose, none
820
    #[arg(
821
        long,
822
        default_value = "auto",
823
        value_name = "STYLE",
824
        help_heading = "Output & Reports"
825
    )]
826
    pub progress: String,
827

828
    /// Report proto API coverage after the run
829
    #[arg(long, default_value_t = false, help_heading = "Output & Reports")]
830
    pub coverage: bool,
831

832
    /// Coverage format: text, json, html
833
    #[arg(
834
        long,
835
        default_value = "text",
836
        value_name = "FORMAT",
837
        help_heading = "Output & Reports"
838
    )]
839
    pub coverage_format: String,
840

841
    /// Force-capture the request/response exchange even when the active
842
    /// reporter wouldn't otherwise need it (e.g. plain console, or a report
843
    /// format that doesn't render it)
844
    #[arg(long, default_value_t = false, help_heading = "Output & Reports")]
845
    pub capture_exchange: bool,
846
}
847

848
#[derive(Args, Debug, Clone)]
849
pub struct ReflectArgs {
850
    /// Wire protocol: grpc, grpc-web, connectrpc
851
    #[arg(long, default_value = "grpc", value_name = "PROTOCOL")]
852
    pub protocol: String,
853

854
    /// Service symbol, or service/method symbol (e.g. `pkg.Service/Method`)
855
    pub symbol: Option<String>,
856

857
    /// Server address (host:port); overrides $GRPCTESTIFY_ADDRESS
858
    #[arg(long, value_name = "ADDRESS")]
859
    pub address: Option<String>,
860

861
    /// Plaintext connection (no TLS)
862
    #[arg(long, default_value_t = false)]
863
    pub plaintext: bool,
864

865
    /// Skip TLS certificate verification
866
    #[arg(long, default_value_t = false)]
867
    pub insecure: bool,
868

869
    /// Output format: text, json
870
    #[arg(long, default_value = "text", value_name = "FORMAT")]
871
    pub format: String,
872

873
    /// List all methods with full signatures
874
    #[arg(long, default_value_t = false)]
875
    pub list_methods: bool,
876

877
    /// Only show services/methods whose name contains this substring
878
    /// (case-insensitive)
879
    #[arg(long, value_name = "SUBSTRING")]
880
    pub filter: Option<String>,
881

882
    /// Describe a method's request and response message fields
883
    #[arg(long, value_name = "SERVICE/METHOD")]
884
    pub describe: Option<String>,
885

886
    /// CA certificate path for TLS
887
    #[arg(long)]
888
    pub tls_ca: Option<String>,
889

890
    /// Client certificate path for TLS
891
    #[arg(long)]
892
    pub tls_cert: Option<String>,
893

894
    /// Client key path for TLS
895
    #[arg(long)]
896
    pub tls_key: Option<String>,
897
}
898

899
#[derive(Args, Debug, Clone)]
900
pub struct FmtArgs {
901
    /// Files to format
902
    #[arg(required = true, value_name = "FILES")]
903
    pub files: Vec<PathBuf>,
904

905
    /// Write changes to file instead of stdout
906
    #[arg(short = 'w', long, default_value_t = false)]
907
    pub write: bool,
908
}
909

910
#[derive(Args, Debug, Clone)]
911
pub struct CallArgs {
912
    /// Wire protocol: grpc, grpc-web, connectrpc
913
    #[arg(long, default_value = "grpc", value_name = "PROTOCOL")]
914
    pub protocol: String,
915

916
    /// File to call (omit if using -e)
917
    pub file: Option<PathBuf>,
918

919
    /// Inline endpoint (package.Service/Method), skips file
920
    #[arg(
921
        short = 'e',
922
        long,
923
        value_name = "SERVICE/METHOD",
924
        conflicts_with = "file"
925
    )]
926
    pub endpoint: Option<String>,
927

928
    /// Inline JSON request body (used with -e)
929
    #[arg(short = 'd', long, value_name = "JSON", requires = "endpoint")]
930
    pub data: Option<String>,
931

932
    /// Document index for multi-document .gctf files (1-based)
933
    #[arg(long)]
934
    pub doc_index: Option<usize>,
935

936
    /// Include response headers in output, printed before body (-i)
937
    #[arg(short = 'i', long, default_value_t = false)]
938
    pub include: bool,
939

940
    /// Verbose mode: show request/response metadata (-v)
941
    #[arg(short = 'v', long, default_value_t = false)]
942
    pub verbose: bool,
943

944
    /// Extra verbose mode: verbose output plus timing (-vv)
945
    #[arg(long = "vv", default_value_t = false)]
946
    pub very_verbose: bool,
947

948
    /// Output file (stdout if omitted)
949
    #[arg(short = 'o', long, value_name = "FILE")]
950
    pub output: Option<PathBuf>,
951

952
    /// Dump response headers to file (-D)
953
    #[arg(short = 'D', long)]
954
    pub dump_header: Option<PathBuf>,
955

956
    /// Silent mode (-s)
957
    #[arg(short = 's', long, default_value_t = false)]
958
    pub silent: bool,
959

960
    /// Show errors (-S)
961
    #[arg(short = 'S', long, default_value_t = false)]
962
    pub show_error: bool,
963

964
    /// Connection timeout in seconds
965
    #[arg(long, default_value_t = 30, value_name = "SECS")]
966
    pub connect_timeout: u64,
967

968
    /// Skip TLS certificate verification
969
    #[arg(long, default_value_t = false)]
970
    pub insecure: bool,
971

972
    /// Plaintext connection (no TLS) — overrides any TLS from the file
973
    #[arg(long, default_value_t = false)]
974
    pub plaintext: bool,
975

976
    /// CA certificate path for TLS (overrides the file's TLS section)
977
    #[arg(long)]
978
    pub tls_ca: Option<String>,
979

980
    /// Client certificate path for TLS (overrides the file's TLS section)
981
    #[arg(long)]
982
    pub tls_cert: Option<String>,
983

984
    /// Client key path for TLS (overrides the file's TLS section)
985
    #[arg(long)]
986
    pub tls_key: Option<String>,
987

988
    /// Request timeout in seconds
989
    #[arg(long, default_value_t = 60, value_name = "SECS")]
990
    pub max_time: u64,
991

992
    /// Run as benchmark instead of single call
993
    #[arg(long, default_value_t = false)]
994
    pub bench: bool,
995

996
    /// Benchmark concurrency (with --bench)
997
    #[arg(long, requires = "bench")]
998
    pub concurrency: Option<u32>,
999

1000
    /// Benchmark requests (with --bench)
1001
    #[arg(long, requires = "bench")]
1002
    pub requests: Option<u64>,
1003

1004
    /// Benchmark duration (with --bench), e.g. "30s"
1005
    #[arg(long, requires = "bench")]
1006
    pub duration: Option<String>,
1007
}
1008

1009
#[derive(Args, Debug, Clone)]
1010
pub struct GenArgs {
1011
    /// Output file (stdout if omitted)
1012
    #[arg(short = 'o', long, value_name = "FILE")]
1013
    pub output: Option<PathBuf>,
1014

1015
    #[command(subcommand)]
1016
    pub source: GenSource,
1017
}
1018

1019
#[derive(Subcommand, Debug, Clone)]
1020
pub enum GenSource {
1021
    /// Generate from grpcurl invocation
1022
    Grpcurl(GenGrpcurlArgs),
1023
}
1024

1025
#[derive(Args, Debug, Clone)]
1026
#[command(trailing_var_arg = true)]
1027
pub struct GenGrpcurlArgs {
1028
    /// Execute invocation and append RESPONSE/ERROR
1029
    #[arg(short = 'e', long, default_value_t = false)]
1030
    pub execute: bool,
1031

1032
    /// grpcurl command arguments after `gen grpcurl`
1033
    #[arg(required = true, allow_hyphen_values = true)]
1034
    pub grpcurl_args: Vec<String>,
1035
}
1036

1037
impl Cli {
1038
    /// Get parallel job count (auto-detect if set to "auto")
1039
    pub fn parallel_jobs(&self) -> usize {
55✔
1040
        let parallel = match &self.command {
55✔
1041
            Some(Commands::Run(args)) => &args.parallel,
55✔
1042
            _ => &self.run_args.parallel,
×
1043
        };
1044

1045
        if parallel == "auto" {
55✔
1046
            std::thread::available_parallelism()
35✔
1047
                .ok()
35✔
1048
                .map(|n| n.get())
35✔
1049
                .unwrap_or(4)
35✔
1050
        } else {
1051
            // Clamp to at least 1: buffer_unordered(0) never polls (deadlock).
1052
            parallel.parse().unwrap_or(1).max(1)
20✔
1053
        }
1054
    }
55✔
1055

1056
    /// Get progress mode
1057
    pub fn progress_mode(&self) -> ProgressMode {
96✔
1058
        let progress = match &self.command {
96✔
1059
            Some(Commands::Run(args)) => &args.progress,
96✔
1060
            _ => &self.run_args.progress,
×
1061
        };
1062

1063
        match progress.as_str() {
96✔
1064
            "dots" => ProgressMode::Dots,
96✔
1065
            "bar" => ProgressMode::Dots,
92✔
1066
            "none" => ProgressMode::None,
92✔
1067
            "auto" => {
66✔
1068
                if self.verbose {
62✔
1069
                    ProgressMode::Verbose
14✔
1070
                } else {
1071
                    ProgressMode::Dots
48✔
1072
                }
1073
            }
1074
            _ => ProgressMode::Dots,
4✔
1075
        }
1076
    }
96✔
1077

1078
    /// Get log format (first one, when several were requested)
1079
    pub fn log_format_mode(&self) -> Option<LogFormat> {
2✔
1080
        self.log_format_modes().into_iter().next()
2✔
1081
    }
2✔
1082

1083
    /// Get every requested log format, in order, de-duplicated. `--log-format
1084
    /// junit,html` produces both file reports from a single run.
1085
    pub fn log_format_modes(&self) -> Vec<LogFormat> {
104✔
1086
        let log_format = match &self.command {
104✔
1087
            Some(Commands::Run(args)) => &args.log_format,
104✔
1088
            _ => &self.run_args.log_format,
×
1089
        };
1090

1091
        let Some(raw) = log_format else {
104✔
1092
            return Vec::new();
80✔
1093
        };
1094

1095
        let mut seen = std::collections::HashSet::new();
24✔
1096
        raw.split(',')
24✔
1097
            .map(str::trim)
24✔
1098
            .filter(|s| !s.is_empty())
27✔
1099
            .map(|fmt| match fmt {
27✔
1100
                "junit" => LogFormat::JUnit,
27✔
1101
                "json" => LogFormat::Json,
18✔
1102
                "yaml" => LogFormat::Yaml,
14✔
1103
                "allure" => LogFormat::Allure,
10✔
1104
                "html" => LogFormat::Html,
6✔
1105
                _ => LogFormat::Console,
×
1106
            })
27✔
1107
            .filter(|fmt| seen.insert(*fmt))
27✔
1108
            .collect()
24✔
1109
    }
104✔
1110

1111
    /// Get optimizer level from CLI flag or default for command
1112
    pub fn optimize_level(
62✔
1113
        &self,
62✔
1114
        default: crate::optimizer::OptimizeLevel,
62✔
1115
    ) -> crate::optimizer::OptimizeLevel {
62✔
1116
        use crate::optimizer::OptimizeLevel;
1117
        match self.optimize.as_str() {
62✔
1118
            "0" | "none" => OptimizeLevel::None,
62✔
1119
            "1" | "safe" => OptimizeLevel::Safe,
62✔
1120
            "2" | "advisory" => OptimizeLevel::Advisory,
62✔
1121
            "3" | "aggressive" => OptimizeLevel::Aggressive,
58✔
1122
            _ => default,
57✔
1123
        }
1124
    }
62✔
1125

1126
    /// Helper to get effective RunArgs
1127
    pub fn get_run_args(&self) -> &RunArgs {
3✔
1128
        match &self.command {
3✔
1129
            Some(Commands::Run(args)) => args,
3✔
1130
            _ => &self.run_args,
×
1131
        }
1132
    }
3✔
1133
}
1134

1135
fn is_json_format(value: &str) -> bool {
123✔
1136
    value.eq_ignore_ascii_case("json")
123✔
1137
}
123✔
1138

1139
/// Trait for CLI argument types that have a `--format` option.
1140
pub trait HasFormat {
1141
    fn format(&self) -> &str;
1142

1143
    fn is_json(&self) -> bool {
123✔
1144
        is_json_format(self.format())
123✔
1145
    }
123✔
1146
}
1147

1148
impl HasFormat for ListArgs {
1149
    fn format(&self) -> &str {
3✔
1150
        &self.format
3✔
1151
    }
3✔
1152
}
1153

1154
impl HasFormat for InspectArgs {
1155
    fn format(&self) -> &str {
13✔
1156
        &self.format
13✔
1157
    }
13✔
1158
}
1159

1160
impl HasFormat for ExplainArgs {
1161
    fn format(&self) -> &str {
10✔
1162
        &self.format
10✔
1163
    }
10✔
1164
}
1165

1166
impl HasFormat for GrpcurlArgs {
1167
    fn format(&self) -> &str {
9✔
1168
        &self.format
9✔
1169
    }
9✔
1170
}
1171

1172
impl HasFormat for CheckArgs {
1173
    fn format(&self) -> &str {
88✔
1174
        &self.format
88✔
1175
    }
88✔
1176
}
1177

1178
impl HasFormat for BenchArgs {
1179
    fn format(&self) -> &str {
×
1180
        &self.format
×
1181
    }
×
1182
}
1183

1184
#[derive(Args, Debug, Clone)]
1185
pub struct PlayArgs {
1186
    /// Host/interface to bind. Defaults to loopback only; pass e.g. 0.0.0.0
1187
    /// to expose the playground on the network (no auth — trusted networks only)
1188
    #[arg(long, default_value = "127.0.0.1")]
1189
    pub host: String,
1190

1191
    /// Port to listen on (default: 4755)
1192
    #[arg(long, default_value = "4755")]
1193
    pub port: u16,
1194

1195
    /// Directory with .gctf collections (default: current dir)
1196
    #[arg(long, default_value = ".")]
1197
    pub dir: std::path::PathBuf,
1198

1199
    /// Open browser automatically
1200
    #[arg(long, default_value_t = false)]
1201
    pub open: bool,
1202

1203
    /// Initialize .grpctestify project directory
1204
    #[arg(long, default_value_t = false)]
1205
    pub init: bool,
1206
}
1207

1208
impl RunArgs {
1209
    #[must_use]
1210
    pub fn is_json_coverage(&self) -> bool {
×
1211
        is_json_format(&self.coverage_format)
×
1212
    }
×
1213

1214
    #[must_use]
1215
    pub fn is_html_coverage(&self) -> bool {
×
1216
        self.coverage_format.eq_ignore_ascii_case("html")
×
1217
    }
×
1218
}
1219

1220
#[cfg(test)]
1221
mod tests {
1222
    use super::*;
1223
    use clap::Parser;
1224

1225
    #[test]
1226
    fn parallel_jobs_clamps_zero_to_one() {
1✔
1227
        let cli = Cli::parse_from(["grpctestify", "run", "-p", "0", "test.gctf"]);
1✔
1228
        assert_eq!(cli.parallel_jobs(), 1);
1✔
1229
    }
1✔
1230

1231
    #[test]
1232
    fn parallel_jobs_invalid_defaults_to_one() {
1✔
1233
        let cli = Cli::parse_from(["grpctestify", "run", "-p", "bogus", "test.gctf"]);
1✔
1234
        assert_eq!(cli.parallel_jobs(), 1);
1✔
1235
    }
1✔
1236

1237
    #[test]
1238
    fn parallel_jobs_auto_is_at_least_one() {
1✔
1239
        let cli = Cli::parse_from(["grpctestify", "run", "test.gctf"]);
1✔
1240
        assert!(cli.parallel_jobs() >= 1);
1✔
1241
    }
1✔
1242

1243
    #[test]
1244
    fn log_format_modes_single_format_unchanged() {
1✔
1245
        let cli = Cli::parse_from(["grpctestify", "run", "--log-format", "junit", "t.gctf"]);
1✔
1246
        assert_eq!(cli.log_format_modes(), vec![LogFormat::JUnit]);
1✔
1247
        assert_eq!(cli.log_format_mode(), Some(LogFormat::JUnit));
1✔
1248
    }
1✔
1249

1250
    #[test]
1251
    fn log_format_modes_comma_separated() {
1✔
1252
        let cli = Cli::parse_from(["grpctestify", "run", "--log-format", "junit,html", "t.gctf"]);
1✔
1253
        assert_eq!(
1✔
1254
            cli.log_format_modes(),
1✔
1255
            vec![LogFormat::JUnit, LogFormat::Html]
1✔
1256
        );
1257
    }
1✔
1258

1259
    #[test]
1260
    fn log_format_modes_dedups_and_trims() {
1✔
1261
        let cli = Cli::parse_from([
1✔
1262
            "grpctestify",
1✔
1263
            "run",
1✔
1264
            "--log-format",
1✔
1265
            " junit , html,junit ",
1✔
1266
            "t.gctf",
1✔
1267
        ]);
1✔
1268
        assert_eq!(
1✔
1269
            cli.log_format_modes(),
1✔
1270
            vec![LogFormat::JUnit, LogFormat::Html]
1✔
1271
        );
1272
    }
1✔
1273

1274
    #[test]
1275
    fn log_format_modes_empty_when_unset() {
1✔
1276
        let cli = Cli::parse_from(["grpctestify", "run", "t.gctf"]);
1✔
1277
        assert!(cli.log_format_modes().is_empty());
1✔
1278
        assert_eq!(cli.log_format_mode(), None);
1✔
1279
    }
1✔
1280

1281
    #[test]
1282
    fn capture_exchange_flag_defaults_false_and_parses() {
1✔
1283
        let cli = Cli::parse_from(["grpctestify", "run", "t.gctf"]);
1✔
1284
        assert!(!cli.get_run_args().capture_exchange);
1✔
1285

1286
        let cli = Cli::parse_from(["grpctestify", "run", "--capture-exchange", "t.gctf"]);
1✔
1287
        assert!(cli.get_run_args().capture_exchange);
1✔
1288
    }
1✔
1289

1290
    #[test]
1291
    fn parse_call_defaults() {
1✔
1292
        let cli = Cli::parse_from(["grpctestify", "call", "test.gctf"]);
1✔
1293
        let Some(Commands::Call(call)) = cli.command else {
1✔
1294
            panic!("expected call command");
×
1295
        };
1296

1297
        assert_eq!(call.file, Some(PathBuf::from("test.gctf")));
1✔
1298
        assert_eq!(call.doc_index, None);
1✔
1299
        assert_eq!(call.endpoint, None);
1✔
1300
        assert_eq!(call.data, None);
1✔
1301
        assert!(!call.include);
1✔
1302
        assert!(!call.verbose);
1✔
1303
        assert!(!call.very_verbose);
1✔
1304
        assert!(!call.silent);
1✔
1305
        assert!(!call.show_error);
1✔
1306
        assert_eq!(call.connect_timeout, 30);
1✔
1307
        assert_eq!(call.max_time, 60);
1✔
1308
    }
1✔
1309

1310
    #[test]
1311
    fn parse_call_inline_endpoint() {
1✔
1312
        let cli = Cli::parse_from([
1✔
1313
            "grpctestify",
1✔
1314
            "call",
1✔
1315
            "-e",
1✔
1316
            "svc.Method/Call",
1✔
1317
            "-d",
1✔
1318
            r#"{"name":"test"}"#,
1✔
1319
        ]);
1✔
1320
        let Some(Commands::Call(call)) = cli.command else {
1✔
1321
            panic!("expected call command");
×
1322
        };
1323
        assert_eq!(call.endpoint.as_deref(), Some("svc.Method/Call"));
1✔
1324
        assert_eq!(call.data.as_deref(), Some(r#"{"name":"test"}"#));
1✔
1325
        assert_eq!(call.file, None);
1✔
1326
    }
1✔
1327

1328
    #[test]
1329
    fn parse_call_verbose_flags() {
1✔
1330
        let cli = Cli::parse_from(["grpctestify", "call", "-v", "test.gctf"]);
1✔
1331
        let Some(Commands::Call(call)) = cli.command else {
1✔
1332
            panic!()
×
1333
        };
1334
        assert!(call.verbose);
1✔
1335
        assert!(!call.very_verbose);
1✔
1336

1337
        let cli = Cli::parse_from(["grpctestify", "call", "--vv", "test.gctf"]);
1✔
1338
        let Some(Commands::Call(call)) = cli.command else {
1✔
1339
            panic!()
×
1340
        };
1341
        assert!(!call.verbose);
1✔
1342
        assert!(call.very_verbose);
1✔
1343
    }
1✔
1344

1345
    #[test]
1346
    fn parse_call_include_and_dump_header() {
1✔
1347
        let cli = Cli::parse_from(["grpctestify", "call", "-i", "-D", "/tmp/h.txt", "test.gctf"]);
1✔
1348
        let Some(Commands::Call(call)) = cli.command else {
1✔
1349
            panic!()
×
1350
        };
1351
        assert!(call.include);
1✔
1352
        assert_eq!(call.dump_header, Some(PathBuf::from("/tmp/h.txt")));
1✔
1353
    }
1✔
1354

1355
    #[test]
1356
    fn parse_call_silent_and_show_error() {
1✔
1357
        let cli = Cli::parse_from(["grpctestify", "call", "-s", "-S", "test.gctf"]);
1✔
1358
        let Some(Commands::Call(call)) = cli.command else {
1✔
1359
            panic!()
×
1360
        };
1361
        assert!(call.silent);
1✔
1362
        assert!(call.show_error);
1✔
1363
    }
1✔
1364

1365
    #[test]
1366
    fn parse_gen_with_output_before_source() {
1✔
1367
        let cli = Cli::parse_from([
1✔
1368
            "grpctestify",
1✔
1369
            "gen",
1✔
1370
            "-o",
1✔
1371
            "out.gctf",
1✔
1372
            "grpcurl",
1✔
1373
            "-plaintext",
1✔
1374
            "localhost:4770",
1✔
1375
            "svc.Method/Call",
1✔
1376
        ]);
1✔
1377

1378
        let Some(Commands::Gen(gen_args)) = cli.command else {
1✔
1379
            panic!("expected gen command");
×
1380
        };
1381
        assert_eq!(gen_args.output, Some(PathBuf::from("out.gctf")));
1✔
1382

1383
        let GenSource::Grpcurl(grpcurl) = gen_args.source;
1✔
1384
        assert_eq!(
1✔
1385
            grpcurl.grpcurl_args,
1386
            vec![
1✔
1387
                "-plaintext".to_string(),
1✔
1388
                "localhost:4770".to_string(),
1✔
1389
                "svc.Method/Call".to_string()
1✔
1390
            ]
1391
        );
1392
    }
1✔
1393

1394
    #[test]
1395
    fn parse_gen_grpcurl_preserves_hyphen_args() {
1✔
1396
        let cli = Cli::parse_from([
1✔
1397
            "grpctestify",
1✔
1398
            "gen",
1✔
1399
            "grpcurl",
1✔
1400
            "-H",
1✔
1401
            "x-api-key: abc",
1✔
1402
            "-d",
1✔
1403
            "{}",
1✔
1404
            "localhost:4770",
1✔
1405
            "svc.Method/Call",
1✔
1406
        ]);
1✔
1407

1408
        let Some(Commands::Gen(gen_args)) = cli.command else {
1✔
1409
            panic!("expected gen command");
×
1410
        };
1411

1412
        let GenSource::Grpcurl(grpcurl) = gen_args.source;
1✔
1413
        assert_eq!(grpcurl.grpcurl_args[0], "-H");
1✔
1414
        assert_eq!(grpcurl.grpcurl_args[2], "-d");
1✔
1415
        assert_eq!(grpcurl.grpcurl_args[3], "{}");
1✔
1416
        assert_eq!(grpcurl.grpcurl_args[4], "localhost:4770");
1✔
1417
    }
1✔
1418

1419
    #[test]
1420
    fn parse_bench_extended_options() {
1✔
1421
        let cli = Cli::parse_from([
1✔
1422
            "grpctestify",
1✔
1423
            "bench",
1✔
1424
            "tests/",
1✔
1425
            "-c",
1✔
1426
            "8",
1✔
1427
            "-n",
1✔
1428
            "1000",
1✔
1429
            "--max-duration",
1✔
1430
            "30s",
1✔
1431
            "--connections",
1✔
1432
            "4",
1✔
1433
            "--connect-timeout",
1✔
1434
            "2s",
1✔
1435
            "--keepalive",
1✔
1436
            "10s",
1✔
1437
            "--cpus",
1✔
1438
            "2",
1✔
1439
            "--name",
1✔
1440
            "smoke-bench",
1✔
1441
        ]);
1✔
1442

1443
        let Some(Commands::Bench(args)) = cli.command else {
1✔
1444
            panic!("expected bench command");
×
1445
        };
1446

1447
        assert_eq!(args.test_paths, vec![PathBuf::from("tests/")]);
1✔
1448
        assert_eq!(args.concurrency, Some(8));
1✔
1449
        assert_eq!(args.requests, Some(1000));
1✔
1450
        assert_eq!(args.max_duration.as_deref(), Some("30s"));
1✔
1451
        assert_eq!(args.connections, Some(4));
1✔
1452
        assert_eq!(args.connect_timeout.as_deref(), Some("2s"));
1✔
1453
        assert_eq!(args.keepalive.as_deref(), Some("10s"));
1✔
1454
        assert_eq!(args.cpus, Some(2));
1✔
1455
        assert_eq!(args.name.as_deref(), Some("smoke-bench"));
1✔
1456
    }
1✔
1457

1458
    #[test]
1459
    fn parse_bench_run_style_option_names() {
1✔
1460
        let cli = Cli::parse_from([
1✔
1461
            "grpctestify",
1✔
1462
            "bench",
1✔
1463
            "tests/",
1✔
1464
            "--no-assert",
1✔
1465
            "--assert-mode",
1✔
1466
            "sampled",
1✔
1467
            "--log-format",
1✔
1468
            "json",
1✔
1469
            "--log-output",
1✔
1470
            "bench.json",
1✔
1471
            "--latency-percentiles",
1✔
1472
            "p50,p90,p99",
1✔
1473
            "--duration-stop",
1✔
1474
            "wait",
1✔
1475
            "--progress-interval",
1✔
1476
            "3s",
1✔
1477
            "--ramp-up",
1✔
1478
            "3s",
1✔
1479
            "--warmup",
1✔
1480
            "1s",
1✔
1481
        ]);
1✔
1482

1483
        let Some(Commands::Bench(args)) = cli.command else {
1✔
1484
            panic!("expected bench command");
×
1485
        };
1486

1487
        assert!(args.no_assert);
1✔
1488
        assert_eq!(args.assert_mode.as_deref(), Some("sampled"));
1✔
1489
        assert_eq!(args.format, "json");
1✔
1490
        assert_eq!(args.output, Some(PathBuf::from("bench.json")));
1✔
1491
        assert_eq!(args.latency_percentiles.as_deref(), Some("p50,p90,p99"));
1✔
1492
        assert_eq!(args.duration_stop.as_deref(), Some("wait"));
1✔
1493
        assert_eq!(args.progress_interval.as_deref(), Some("3s"));
1✔
1494
        assert_eq!(args.ramp_up.as_deref(), Some("3s"));
1✔
1495
        assert_eq!(args.warmup.as_deref(), Some("1s"));
1✔
1496
    }
1✔
1497

1498
    #[test]
1499
    fn parse_bench_load_schedule_options() {
1✔
1500
        let cli = Cli::parse_from([
1✔
1501
            "grpctestify",
1✔
1502
            "bench",
1✔
1503
            "tests/",
1✔
1504
            "-c",
1✔
1505
            "10",
1✔
1506
            "-n",
1✔
1507
            "10000",
1✔
1508
            "--load-schedule",
1✔
1509
            "step",
1✔
1510
            "--load-start",
1✔
1511
            "50",
1✔
1512
            "--load-end",
1✔
1513
            "150",
1✔
1514
            "--load-step",
1✔
1515
            "10",
1✔
1516
            "--load-step-duration",
1✔
1517
            "5s",
1✔
1518
            "--load-max-duration",
1✔
1519
            "40s",
1✔
1520
        ]);
1✔
1521

1522
        let Some(Commands::Bench(args)) = cli.command else {
1✔
1523
            panic!("expected bench command");
×
1524
        };
1525

1526
        assert_eq!(args.concurrency, Some(10));
1✔
1527
        assert_eq!(args.requests, Some(10000));
1✔
1528
        assert_eq!(args.load_schedule.as_deref(), Some("step"));
1✔
1529
        assert_eq!(args.load_start, Some(50.0));
1✔
1530
        assert_eq!(args.load_end, Some(150.0));
1✔
1531
        assert_eq!(args.load_step, Some(10.0));
1✔
1532
        assert_eq!(args.load_step_duration.as_deref(), Some("5s"));
1✔
1533
        assert_eq!(args.load_max_duration.as_deref(), Some("40s"));
1✔
1534
    }
1✔
1535

1536
    #[test]
1537
    fn parse_index_command() {
1✔
1538
        let cli = Cli::parse_from([
1✔
1539
            "grpctestify",
1✔
1540
            "index",
1✔
1541
            "tests/bench/user_lookup.gctf",
1✔
1542
            "--force",
1✔
1543
        ]);
1✔
1544

1545
        let Some(Commands::Index(args)) = cli.command else {
1✔
1546
            panic!("expected index command");
×
1547
        };
1548
        assert_eq!(
1✔
1549
            args.sources,
1550
            vec![PathBuf::from("tests/bench/user_lookup.gctf")]
1✔
1551
        );
1552
        assert!(args.force);
1✔
1553
    }
1✔
1554
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc