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

gripmock / grpctestify-rust / 24905917607

24 Apr 2026 06:38PM UTC coverage: 78.02% (+0.3%) from 77.729%
24905917607

Pull #43

github

web-flow
Merge f552abec5 into 017e47d15
Pull Request #43: new command gen grpcurl & call

741 of 993 new or added lines in 24 files covered. (74.62%)

3 existing lines in 3 files now uncovered.

19594 of 25114 relevant lines covered (78.02%)

39580.44 hits per line

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

83.01
/src/cli/args.rs
1
// CLI argument definitions using Clap
2

3
use clap::{Args, Parser, Subcommand};
4
use std::path::PathBuf;
5

6
/// Progress indicator modes
7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8
pub enum ProgressMode {
9
    Dots,
10
    Bar,
11
    None,
12
    Verbose,
13
}
14

15
impl std::str::FromStr for ProgressMode {
16
    type Err = ();
17

18
    fn from_str(s: &str) -> Result<Self, Self::Err> {
4✔
19
        match s {
4✔
20
            "dots" => Ok(Self::Dots),
4✔
21
            "bar" => Ok(Self::Bar),
3✔
22
            "none" => Ok(Self::None),
2✔
23
            _ => Ok(Self::Dots),
1✔
24
        }
25
    }
4✔
26
}
27

28
/// Log format types
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
pub enum LogFormat {
31
    Console,
32
    Json,
33
    JUnit,
34
    Allure,
35
}
36

37
/// gRPC testing utility written in Rust
38
#[derive(Parser, Debug)]
39
#[command(name = "grpctestify")]
40
#[command(author = "grpctestify team")]
41
#[command(version = env!("CARGO_PKG_VERSION"))]
42
#[command(about = "Test gRPC services with simple .gctf files", long_about = None)]
43
pub struct Cli {
44
    #[command(subcommand)]
45
    pub command: Option<Commands>,
46

47
    // Flatten RunArgs to support implicit run command at top-level.
48
    // This allows `grpctestify tests/` to work as expected.
49
    #[command(flatten)]
50
    pub run_args: RunArgs,
51

52
    /// Enable verbose debug output
53
    #[arg(short = 'v', long, global = true, default_value_t = false)]
54
    pub verbose: bool,
55

56
    /// Install shell completion (bash, zsh, fish, elvish, powershell)
57
    #[arg(long, value_name = "SHELL_TYPE", value_parser = ["bash", "zsh", "fish", "elvish", "powershell"])]
58
    pub completion: Option<String>,
59
}
60

61
#[derive(Subcommand, Debug)]
62
pub enum Commands {
63
    /// Run tests (default)
64
    Run(Box<RunArgs>),
65
    /// Call gRPC endpoint without assertions
66
    Call(CallArgs),
67
    /// Generate .gctf file from external invocations
68
    Gen(GenArgs),
69
    /// Reflect gRPC service and list methods
70
    Reflect(ReflectArgs),
71
    /// Format files
72
    Fmt(FmtArgs),
73
    /// Validate files
74
    Check(CheckArgs),
75
    /// Show test information
76
    Inspect(InspectArgs),
77
    /// Explain test execution flow
78
    Explain(ExplainArgs),
79
    /// Generate grpcurl invocation from a .gctf file
80
    Grpcurl(GrpcurlArgs),
81
    /// List discovered .gctf test files
82
    List(ListArgs),
83
    /// LSP server
84
    Lsp(LspArgs),
85
}
86

87
#[derive(Args, Debug, Clone)]
88
pub struct GrpcurlArgs {
89
    /// File to convert into grpcurl command
90
    #[arg(required = true)]
91
    pub file: PathBuf,
92

93
    /// Document index for multi-document .gctf files (1-based)
94
    #[arg(long)]
95
    pub doc_index: Option<usize>,
96

97
    /// Output format (text, json)
98
    #[arg(long, default_value = "text")]
99
    pub format: String,
100
}
101

102
#[derive(Args, Debug, Clone)]
103
pub struct LspArgs {
104
    /// Use stdio for communication (default)
105
    #[arg(long, default_value_t = true)]
106
    pub stdio: bool,
107
}
108

109
#[derive(Args, Debug, Clone)]
110
pub struct ListArgs {
111
    /// Path to test file or directory to list
112
    #[arg(required = false)]
113
    pub path: Option<PathBuf>,
114

115
    /// Output format (text, json)
116
    #[arg(long, default_value = "json")]
117
    pub format: String,
118

119
    /// Include test range information
120
    #[arg(long, default_value_t = false)]
121
    pub with_range: bool,
122
}
123

124
#[derive(Args, Debug, Clone)]
125
pub struct InspectArgs {
126
    /// File to inspect
127
    #[arg(required = true)]
128
    pub file: PathBuf,
129

130
    /// Output format (text, json)
131
    #[arg(long, default_value = "text")]
132
    pub format: String,
133
}
134

135
#[derive(Args, Debug, Clone)]
136
pub struct ExplainArgs {
137
    /// File to explain
138
    #[arg(required = true)]
139
    pub file: PathBuf,
140

141
    /// Output format (text, json)
142
    #[arg(long, default_value = "text")]
143
    pub format: String,
144
}
145

146
#[derive(Args, Debug, Clone)]
147
pub struct CheckArgs {
148
    /// Files to validate
149
    #[arg(required = true)]
150
    pub files: Vec<PathBuf>,
151

152
    /// Output format (text, json)
153
    #[arg(long, default_value = "text")]
154
    pub format: String,
155
}
156

157
#[derive(Args, Debug, Clone)]
158
pub struct RunArgs {
159
    /// Path to test file or directory to execute
160
    // We make this optional so it doesn't conflict with subcommands when parsed at top level,
161
    // but we'll enforce it manually if no subcommand is present.
162
    // However, if we use `flatten` at top level, and `subcommand` is optional,
163
    // Clap might be ambiguous if `test_paths` matches a subcommand name.
164
    // But since `test_paths` are files/dirs, usually they won't clash with "run", "reflect", etc.
165
    // We remove `required` constraint here and handle validation manually.
166
    #[arg(required = false)]
167
    pub test_paths: Vec<PathBuf>,
168

169
    /// Exclude files/directories matching the given glob pattern (can be used multiple times)
170
    #[arg(long = "exclude", value_name = "PATTERN")]
171
    pub exclude: Vec<String>,
172

173
    /// Filter by tags (AND - file must have ALL tags)
174
    #[arg(long = "tags", value_name = "TAGS")]
175
    pub tags: Vec<String>,
176

177
    /// Skip files with these tags (NOT OR - exclude if ANY matches)
178
    #[arg(long = "skip-tags", value_name = "TAGS")]
179
    pub skip_tags: Vec<String>,
180

181
    /// Run tests in parallel with N workers
182
    #[arg(short = 'p', long, default_value = "auto")]
183
    pub parallel: String,
184

185
    /// Show commands that would be executed without running them
186
    #[arg(short = 'd', long, default_value_t = false)]
187
    pub dry_run: bool,
188

189
    /// Sort test files by type
190
    #[arg(short = 's', long, default_value = "path")]
191
    pub sort: String,
192

193
    /// Generate test reports in specified format
194
    #[arg(long, value_name = "FORMAT")]
195
    pub log_format: Option<String>,
196

197
    /// Output file for test reports (use with --log-format)
198
    #[arg(long, value_name = "OUTPUT_FILE")]
199
    pub log_output: Option<PathBuf>,
200

201
    /// Output streaming JSON events (for IDE integration)
202
    #[arg(long, default_value_t = false)]
203
    pub stream: bool,
204

205
    /// Set timeout for individual tests (seconds)
206
    #[arg(short = 't', long, default_value_t = 30)]
207
    pub timeout: u64,
208

209
    /// Number of retries for failed network calls
210
    #[arg(short = 'r', long, default_value_t = 0)]
211
    pub retry: u32,
212

213
    /// Initial delay between retries (seconds)
214
    #[arg(long, default_value_t = 1.0)]
215
    pub retry_delay: f64,
216

217
    /// Disable retry mechanisms completely
218
    #[arg(long, default_value_t = false)]
219
    pub no_retry: bool,
220

221
    /// Progress indicator style
222
    #[arg(long, default_value = "auto")]
223
    pub progress: String,
224

225
    /// Skip assertion evaluation and print raw server responses
226
    #[arg(long, default_value_t = false)]
227
    pub no_assert: bool,
228

229
    /// Generate Proto API coverage report
230
    #[arg(long, default_value_t = false)]
231
    pub coverage: bool,
232

233
    /// Coverage output format (text, json)
234
    #[arg(long, default_value = "text")]
235
    pub coverage_format: String,
236

237
    /// Write/Overwrite test files with actual server responses (Snapshot Mode)
238
    #[arg(short = 'w', long, default_value_t = false)]
239
    pub write: bool,
240
}
241

242
#[derive(Args, Debug, Clone)]
243
pub struct ReflectArgs {
244
    /// Service/method symbol OR .gctf file path
245
    pub symbol: Option<String>,
246

247
    /// Server address (overrides environment variable)
248
    #[arg(long)]
249
    pub address: Option<String>,
250

251
    /// Plaintext connection (no TLS). If omitted, localhost/http addresses default to plaintext.
252
    #[arg(long, default_value_t = false)]
253
    pub plaintext: bool,
254
}
255

256
#[derive(Args, Debug, Clone)]
257
pub struct FmtArgs {
258
    /// Files to format
259
    #[arg(required = true)]
260
    pub files: Vec<PathBuf>,
261

262
    /// Write changes to file instead of stdout
263
    #[arg(short = 'w', long, default_value_t = false)]
264
    pub write: bool,
265
}
266

267
#[derive(Args, Debug, Clone)]
268
pub struct CallArgs {
269
    /// File to call
270
    #[arg(required = true)]
271
    pub file: PathBuf,
272

273
    /// Document index for multi-document .gctf files (1-based)
274
    #[arg(long)]
275
    pub doc_index: Option<usize>,
276

277
    /// Include response headers in output, printed before body (-i)
278
    #[arg(short = 'i', long, default_value_t = false)]
279
    pub include: bool,
280

281
    /// Verbose mode: show request/response metadata (-v)
282
    #[arg(short = 'v', long, default_value_t = false)]
283
    pub verbose: bool,
284

285
    /// Extra verbose mode: verbose output plus timing (-vv)
286
    #[arg(long = "vv", default_value_t = false)]
287
    pub very_verbose: bool,
288

289
    /// Output to file instead of stdout (-o)
290
    #[arg(short = 'o', long)]
291
    pub output: Option<PathBuf>,
292

293
    /// Dump response headers to file (-D)
294
    #[arg(short = 'D', long)]
295
    pub dump_header: Option<PathBuf>,
296

297
    /// Silent mode (-s)
298
    #[arg(short = 's', long, default_value_t = false)]
299
    pub silent: bool,
300

301
    /// Show errors (-S)
302
    #[arg(short = 'S', long, default_value_t = false)]
303
    pub show_error: bool,
304

305
    /// Connection timeout in seconds
306
    #[arg(long, default_value_t = 30)]
307
    pub connect_timeout: u64,
308

309
    /// Request timeout in seconds
310
    #[arg(long, default_value_t = 60)]
311
    pub max_time: u64,
312
}
313

314
#[derive(Args, Debug, Clone)]
315
pub struct GenArgs {
316
    /// Output file for generated gctf (stdout if omitted)
317
    #[arg(short = 'o', long)]
318
    pub output: Option<PathBuf>,
319

320
    #[command(subcommand)]
321
    pub source: GenSource,
322
}
323

324
#[derive(Subcommand, Debug, Clone)]
325
pub enum GenSource {
326
    /// Generate from grpcurl invocation
327
    Grpcurl(GenGrpcurlArgs),
328
}
329

330
#[derive(Args, Debug, Clone)]
331
#[command(trailing_var_arg = true)]
332
pub struct GenGrpcurlArgs {
333
    /// Execute invocation and append RESPONSE/ERROR
334
    #[arg(short = 'e', long, default_value_t = false)]
335
    pub execute: bool,
336

337
    /// grpcurl command arguments after `gen grpcurl`
338
    #[arg(required = true, allow_hyphen_values = true)]
339
    pub grpcurl_args: Vec<String>,
340
}
341

342
impl Cli {
343
    /// Get parallel job count (auto-detect if set to "auto")
344
    pub fn parallel_jobs(&self) -> usize {
1✔
345
        let parallel = match &self.command {
1✔
346
            Some(Commands::Run(args)) => &args.parallel,
1✔
347
            _ => &self.run_args.parallel,
×
348
        };
349

350
        if parallel == "auto" {
1✔
351
            // Auto-detect CPU count
352
            std::thread::available_parallelism()
1✔
353
                .ok()
1✔
354
                .map(|n| n.get())
1✔
355
                .unwrap_or(4)
1✔
356
        } else {
357
            parallel.parse().unwrap_or(1)
×
358
        }
359
    }
1✔
360

361
    /// Get progress mode
362
    pub fn progress_mode(&self) -> ProgressMode {
1✔
363
        let progress = match &self.command {
1✔
364
            Some(Commands::Run(args)) => &args.progress,
1✔
365
            _ => &self.run_args.progress,
×
366
        };
367

368
        match progress.as_str() {
1✔
369
            "dots" => ProgressMode::Dots,
1✔
370
            "bar" => ProgressMode::Bar,
1✔
371
            "none" => ProgressMode::None,
1✔
372
            "auto" => {
1✔
373
                if self.verbose {
1✔
374
                    ProgressMode::Verbose
×
375
                } else {
376
                    ProgressMode::Dots
1✔
377
                }
378
            }
379
            _ => ProgressMode::Dots,
×
380
        }
381
    }
1✔
382

383
    /// Get log format
384
    pub fn log_format_mode(&self) -> Option<LogFormat> {
1✔
385
        let log_format = match &self.command {
1✔
386
            Some(Commands::Run(args)) => &args.log_format,
1✔
387
            _ => &self.run_args.log_format,
×
388
        };
389

390
        log_format.as_ref().map(|fmt| match fmt.as_str() {
1✔
391
            "junit" => LogFormat::JUnit,
×
392
            "json" => LogFormat::Json,
×
393
            "allure" => LogFormat::Allure,
×
394
            _ => LogFormat::Console,
×
395
        })
×
396
    }
1✔
397

398
    /// Helper to get effective RunArgs
399
    pub fn get_run_args(&self) -> &RunArgs {
×
400
        match &self.command {
×
401
            Some(Commands::Run(args)) => args,
×
402
            _ => &self.run_args,
×
403
        }
404
    }
×
405
}
406

407
fn is_json_format(value: &str) -> bool {
23✔
408
    value.eq_ignore_ascii_case("json")
23✔
409
}
23✔
410

411
/// Trait for CLI argument types that have a `--format` option.
412
pub trait HasFormat {
413
    fn format(&self) -> &str;
414

415
    fn is_json(&self) -> bool {
23✔
416
        is_json_format(self.format())
23✔
417
    }
23✔
418
}
419

420
impl HasFormat for ListArgs {
421
    fn format(&self) -> &str {
3✔
422
        &self.format
3✔
423
    }
3✔
424
}
425

426
impl HasFormat for InspectArgs {
427
    fn format(&self) -> &str {
4✔
428
        &self.format
4✔
429
    }
4✔
430
}
431

432
impl HasFormat for ExplainArgs {
433
    fn format(&self) -> &str {
3✔
434
        &self.format
3✔
435
    }
3✔
436
}
437

438
impl HasFormat for GrpcurlArgs {
439
    fn format(&self) -> &str {
9✔
440
        &self.format
9✔
441
    }
9✔
442
}
443

444
impl HasFormat for CheckArgs {
445
    fn format(&self) -> &str {
4✔
446
        &self.format
4✔
447
    }
4✔
448
}
449

450
impl RunArgs {
451
    pub fn is_json_coverage(&self) -> bool {
×
452
        is_json_format(&self.coverage_format)
×
453
    }
×
454
}
455

456
#[cfg(test)]
457
mod tests {
458
    use super::*;
459
    use clap::Parser;
460

461
    #[test]
462
    fn parse_call_defaults() {
1✔
463
        let cli = Cli::parse_from(["grpctestify", "call", "test.gctf"]);
1✔
464
        let Some(Commands::Call(call)) = cli.command else {
1✔
NEW
465
            panic!("expected call command");
×
466
        };
467

468
        assert_eq!(call.file, PathBuf::from("test.gctf"));
1✔
469
        assert_eq!(call.doc_index, None);
1✔
470
        assert!(!call.include);
1✔
471
        assert!(!call.verbose);
1✔
472
        assert!(!call.very_verbose);
1✔
473
        assert!(!call.silent);
1✔
474
        assert!(!call.show_error);
1✔
475
        assert_eq!(call.connect_timeout, 30);
1✔
476
        assert_eq!(call.max_time, 60);
1✔
477
    }
1✔
478

479
    #[test]
480
    fn parse_call_verbose_flags() {
1✔
481
        let cli = Cli::parse_from(["grpctestify", "call", "-v", "test.gctf"]);
1✔
482
        let Some(Commands::Call(call)) = cli.command else {
1✔
NEW
483
            panic!()
×
484
        };
485
        assert!(call.verbose);
1✔
486
        assert!(!call.very_verbose);
1✔
487

488
        let cli = Cli::parse_from(["grpctestify", "call", "--vv", "test.gctf"]);
1✔
489
        let Some(Commands::Call(call)) = cli.command else {
1✔
NEW
490
            panic!()
×
491
        };
492
        assert!(!call.verbose);
1✔
493
        assert!(call.very_verbose);
1✔
494
    }
1✔
495

496
    #[test]
497
    fn parse_call_include_and_dump_header() {
1✔
498
        let cli = Cli::parse_from(["grpctestify", "call", "-i", "-D", "/tmp/h.txt", "test.gctf"]);
1✔
499
        let Some(Commands::Call(call)) = cli.command else {
1✔
NEW
500
            panic!()
×
501
        };
502
        assert!(call.include);
1✔
503
        assert_eq!(call.dump_header, Some(PathBuf::from("/tmp/h.txt")));
1✔
504
    }
1✔
505

506
    #[test]
507
    fn parse_call_silent_and_show_error() {
1✔
508
        let cli = Cli::parse_from(["grpctestify", "call", "-s", "-S", "test.gctf"]);
1✔
509
        let Some(Commands::Call(call)) = cli.command else {
1✔
NEW
510
            panic!()
×
511
        };
512
        assert!(call.silent);
1✔
513
        assert!(call.show_error);
1✔
514
    }
1✔
515

516
    #[test]
517
    fn parse_gen_with_output_before_source() {
1✔
518
        let cli = Cli::parse_from([
1✔
519
            "grpctestify",
1✔
520
            "gen",
1✔
521
            "-o",
1✔
522
            "out.gctf",
1✔
523
            "grpcurl",
1✔
524
            "-plaintext",
1✔
525
            "localhost:4770",
1✔
526
            "svc.Method/Call",
1✔
527
        ]);
1✔
528

529
        let Some(Commands::Gen(gen_args)) = cli.command else {
1✔
NEW
530
            panic!("expected gen command");
×
531
        };
532
        assert_eq!(gen_args.output, Some(PathBuf::from("out.gctf")));
1✔
533

534
        let GenSource::Grpcurl(grpcurl) = gen_args.source;
1✔
535
        assert_eq!(
1✔
536
            grpcurl.grpcurl_args,
537
            vec![
1✔
538
                "-plaintext".to_string(),
1✔
539
                "localhost:4770".to_string(),
1✔
540
                "svc.Method/Call".to_string()
1✔
541
            ]
542
        );
543
    }
1✔
544

545
    #[test]
546
    fn parse_gen_grpcurl_preserves_hyphen_args() {
1✔
547
        let cli = Cli::parse_from([
1✔
548
            "grpctestify",
1✔
549
            "gen",
1✔
550
            "grpcurl",
1✔
551
            "-H",
1✔
552
            "x-api-key: abc",
1✔
553
            "-d",
1✔
554
            "{}",
1✔
555
            "localhost:4770",
1✔
556
            "svc.Method/Call",
1✔
557
        ]);
1✔
558

559
        let Some(Commands::Gen(gen_args)) = cli.command else {
1✔
NEW
560
            panic!("expected gen command");
×
561
        };
562

563
        let GenSource::Grpcurl(grpcurl) = gen_args.source;
1✔
564
        assert_eq!(grpcurl.grpcurl_args[0], "-H");
1✔
565
        assert_eq!(grpcurl.grpcurl_args[2], "-d");
1✔
566
        assert_eq!(grpcurl.grpcurl_args[3], "{}");
1✔
567
        assert_eq!(grpcurl.grpcurl_args[4], "localhost:4770");
1✔
568
    }
1✔
569
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc