1use std::path::Path;
2
3use thiserror::Error;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum CmdSnippetKind {
7 Graphql,
8 Rest,
9 Grpc,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CmdSnippet {
14 Graphql(GraphqlCallSnippet),
15 Rest(RestCallSnippet),
16 Grpc(GrpcCallSnippet),
17}
18
19impl CmdSnippet {
20 pub fn kind(&self) -> CmdSnippetKind {
21 match self {
22 CmdSnippet::Graphql(_) => CmdSnippetKind::Graphql,
23 CmdSnippet::Rest(_) => CmdSnippetKind::Rest,
24 CmdSnippet::Grpc(_) => CmdSnippetKind::Grpc,
25 }
26 }
27
28 pub fn command_basename(&self) -> &str {
29 match self {
30 CmdSnippet::Graphql(s) => &s.command_basename,
31 CmdSnippet::Rest(s) => &s.command_basename,
32 CmdSnippet::Grpc(s) => &s.command_basename,
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GraphqlCallSnippet {
39 pub command_basename: String,
40 pub config_dir: Option<String>,
41 pub env: Option<String>,
42 pub url: Option<String>,
43 pub jwt: Option<String>,
44 pub operation: String,
45 pub variables: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RestCallSnippet {
50 pub command_basename: String,
51 pub config_dir: Option<String>,
52 pub env: Option<String>,
53 pub url: Option<String>,
54 pub token: Option<String>,
55 pub request: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct GrpcCallSnippet {
60 pub command_basename: String,
61 pub config_dir: Option<String>,
62 pub env: Option<String>,
63 pub url: Option<String>,
64 pub token: Option<String>,
65 pub request: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ReportFromCmd {
70 Graphql(GraphqlReportFromCmd),
71 Rest(RestReportFromCmd),
72 Grpc(GrpcReportFromCmd),
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct GraphqlReportFromCmd {
77 pub case: String,
78 pub config_dir: Option<String>,
79 pub env: Option<String>,
80 pub url: Option<String>,
81 pub jwt: Option<String>,
82 pub op: String,
83 pub vars: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct RestReportFromCmd {
88 pub case: String,
89 pub config_dir: Option<String>,
90 pub env: Option<String>,
91 pub url: Option<String>,
92 pub token: Option<String>,
93 pub request: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct GrpcReportFromCmd {
98 pub case: String,
99 pub config_dir: Option<String>,
100 pub env: Option<String>,
101 pub url: Option<String>,
102 pub token: Option<String>,
103 pub request: String,
104}
105
106#[derive(Debug, Error)]
107pub enum CmdSnippetError {
108 #[error("command snippet is empty")]
109 EmptySnippet,
110
111 #[error("failed to tokenize snippet: {message}")]
112 TokenizeFailed { message: String },
113
114 #[error("unsupported command: {command}")]
115 UnsupportedCommand { command: String },
116
117 #[error("expected a `call` snippet; found subcommand: {subcommand}")]
118 UnsupportedSubcommand { subcommand: String },
119
120 #[error("flag {flag} requires a value")]
121 MissingFlagValue { flag: String },
122
123 #[error("unknown flag: {flag}")]
124 UnknownFlag { flag: String },
125
126 #[error("missing GraphQL operation file path (*.graphql)")]
127 MissingGraphqlOperation,
128
129 #[error("missing REST request file path (*.request.json)")]
130 MissingRestRequest,
131
132 #[error("missing gRPC request file path (*.grpc.json)")]
133 MissingGrpcRequest,
134
135 #[error("unexpected extra argument: {arg}")]
136 UnexpectedArg { arg: String },
137}
138
139pub fn parse_call_snippet(snippet: &str) -> Result<CmdSnippet, CmdSnippetError> {
140 let tokens = tokenize_call_snippet(snippet)?;
141 let (cmd, rest) = match tokens.split_first() {
142 Some(v) => v,
143 None => return Err(CmdSnippetError::EmptySnippet),
144 };
145
146 let cmd_base = basename(cmd);
147 match cmd_base.as_str() {
148 "api-gql" | "gql.sh" => Ok(CmdSnippet::Graphql(parse_graphql_call_args(
149 cmd_base, rest,
150 )?)),
151 "api-rest" | "rest.sh" => Ok(CmdSnippet::Rest(parse_rest_call_args(cmd_base, rest)?)),
152 "api-grpc" | "grpc.sh" => Ok(CmdSnippet::Grpc(parse_grpc_call_args(cmd_base, rest)?)),
153 _ => Err(CmdSnippetError::UnsupportedCommand { command: cmd_base }),
154 }
155}
156
157pub fn parse_report_from_cmd_snippet(snippet: &str) -> Result<ReportFromCmd, CmdSnippetError> {
158 let parsed = parse_call_snippet(snippet)?;
159 Ok(match parsed {
160 CmdSnippet::Graphql(s) => ReportFromCmd::Graphql(graphql_to_report_from_cmd(&s)),
161 CmdSnippet::Rest(s) => ReportFromCmd::Rest(rest_to_report_from_cmd(&s)),
162 CmdSnippet::Grpc(s) => ReportFromCmd::Grpc(grpc_to_report_from_cmd(&s)),
163 })
164}
165
166fn graphql_to_report_from_cmd(s: &GraphqlCallSnippet) -> GraphqlReportFromCmd {
167 GraphqlReportFromCmd {
168 case: derive_graphql_case_name(s),
169 config_dir: s.config_dir.clone(),
170 env: s.env.clone(),
171 url: s.url.clone(),
172 jwt: s.jwt.clone(),
173 op: s.operation.clone(),
174 vars: s.variables.clone(),
175 }
176}
177
178fn rest_to_report_from_cmd(s: &RestCallSnippet) -> RestReportFromCmd {
179 RestReportFromCmd {
180 case: derive_rest_case_name(s),
181 config_dir: s.config_dir.clone(),
182 env: s.env.clone(),
183 url: s.url.clone(),
184 token: s.token.clone(),
185 request: s.request.clone(),
186 }
187}
188
189fn grpc_to_report_from_cmd(s: &GrpcCallSnippet) -> GrpcReportFromCmd {
190 GrpcReportFromCmd {
191 case: derive_grpc_case_name(s),
192 config_dir: s.config_dir.clone(),
193 env: s.env.clone(),
194 url: s.url.clone(),
195 token: s.token.clone(),
196 request: s.request.clone(),
197 }
198}
199
200fn parse_graphql_call_args(
201 command_basename: String,
202 raw_args: &[String],
203) -> Result<GraphqlCallSnippet, CmdSnippetError> {
204 let mut config_dir: Option<String> = None;
205 let mut env: Option<String> = None;
206 let mut url: Option<String> = None;
207 let mut jwt: Option<String> = None;
208
209 let mut args: Vec<String> = raw_args.to_vec();
210 if let Some(first) = args.first().cloned()
211 && !first.starts_with('-')
212 && first != "--"
213 {
214 if first == "call" {
215 args.remove(0);
216 } else if matches!(first.as_str(), "history" | "report" | "schema") {
217 return Err(CmdSnippetError::UnsupportedSubcommand { subcommand: first });
218 }
219 }
220
221 let mut positional: Vec<String> = Vec::new();
222 let mut i: usize = 0;
223 while i < args.len() {
224 let arg = args[i].as_str();
225 if arg == "--" {
226 positional.extend(args[i + 1..].iter().cloned());
227 break;
228 }
229
230 if arg == "--no-history" || arg == "--list-envs" || arg == "--list-jwts" {
231 i += 1;
232 continue;
233 }
234
235 if let Some(v) = flag_value_eq(arg, "--config-dir") {
236 config_dir = Some(v?);
237 i += 1;
238 continue;
239 }
240 if arg == "--config-dir" {
241 config_dir = Some(take_value(&args, i, "--config-dir")?);
242 i += 2;
243 continue;
244 }
245
246 if let Some(v) = flag_value_eq(arg, "--env") {
247 env = Some(v?);
248 i += 1;
249 continue;
250 }
251 if arg == "--env" || arg == "-e" {
252 env = Some(take_value(&args, i, arg)?);
253 i += 2;
254 continue;
255 }
256
257 if let Some(v) = flag_value_eq(arg, "--url") {
258 url = Some(v?);
259 i += 1;
260 continue;
261 }
262 if arg == "--url" || arg == "-u" {
263 url = Some(take_value(&args, i, arg)?);
264 i += 2;
265 continue;
266 }
267
268 if let Some(v) = flag_value_eq(arg, "--jwt") {
269 jwt = Some(v?);
270 i += 1;
271 continue;
272 }
273 if arg == "--jwt" {
274 jwt = Some(take_value(&args, i, "--jwt")?);
275 i += 2;
276 continue;
277 }
278
279 if arg.starts_with('-') {
280 return Err(CmdSnippetError::UnknownFlag {
281 flag: arg.to_string(),
282 });
283 }
284
285 positional.push(arg.to_string());
286 i += 1;
287 }
288
289 let operation = positional
290 .first()
291 .cloned()
292 .ok_or(CmdSnippetError::MissingGraphqlOperation)?;
293 let variables = positional.get(1).cloned();
294 if let Some(extra) = positional.get(2) {
295 return Err(CmdSnippetError::UnexpectedArg { arg: extra.clone() });
296 }
297
298 Ok(GraphqlCallSnippet {
299 command_basename,
300 config_dir,
301 env,
302 url,
303 jwt,
304 operation,
305 variables,
306 })
307}
308
309fn parse_rest_call_args(
310 command_basename: String,
311 raw_args: &[String],
312) -> Result<RestCallSnippet, CmdSnippetError> {
313 let mut config_dir: Option<String> = None;
314 let mut env: Option<String> = None;
315 let mut url: Option<String> = None;
316 let mut token: Option<String> = None;
317
318 let mut args: Vec<String> = raw_args.to_vec();
319 if let Some(first) = args.first().cloned()
320 && !first.starts_with('-')
321 && first != "--"
322 {
323 if first == "call" {
324 args.remove(0);
325 } else if matches!(first.as_str(), "history" | "report") {
326 return Err(CmdSnippetError::UnsupportedSubcommand { subcommand: first });
327 }
328 }
329
330 let mut positional: Vec<String> = Vec::new();
331 let mut i: usize = 0;
332 while i < args.len() {
333 let arg = args[i].as_str();
334 if arg == "--" {
335 positional.extend(args[i + 1..].iter().cloned());
336 break;
337 }
338
339 if arg == "--no-history" {
340 i += 1;
341 continue;
342 }
343
344 if let Some(v) = flag_value_eq(arg, "--config-dir") {
345 config_dir = Some(v?);
346 i += 1;
347 continue;
348 }
349 if arg == "--config-dir" {
350 config_dir = Some(take_value(&args, i, "--config-dir")?);
351 i += 2;
352 continue;
353 }
354
355 if let Some(v) = flag_value_eq(arg, "--env") {
356 env = Some(v?);
357 i += 1;
358 continue;
359 }
360 if arg == "--env" || arg == "-e" {
361 env = Some(take_value(&args, i, arg)?);
362 i += 2;
363 continue;
364 }
365
366 if let Some(v) = flag_value_eq(arg, "--url") {
367 url = Some(v?);
368 i += 1;
369 continue;
370 }
371 if arg == "--url" || arg == "-u" {
372 url = Some(take_value(&args, i, arg)?);
373 i += 2;
374 continue;
375 }
376
377 if let Some(v) = flag_value_eq(arg, "--token") {
378 token = Some(v?);
379 i += 1;
380 continue;
381 }
382 if arg == "--token" {
383 token = Some(take_value(&args, i, "--token")?);
384 i += 2;
385 continue;
386 }
387
388 if arg.starts_with('-') {
389 return Err(CmdSnippetError::UnknownFlag {
390 flag: arg.to_string(),
391 });
392 }
393
394 positional.push(arg.to_string());
395 i += 1;
396 }
397
398 let request = positional
399 .first()
400 .cloned()
401 .ok_or(CmdSnippetError::MissingRestRequest)?;
402 if let Some(extra) = positional.get(1) {
403 return Err(CmdSnippetError::UnexpectedArg { arg: extra.clone() });
404 }
405
406 Ok(RestCallSnippet {
407 command_basename,
408 config_dir,
409 env,
410 url,
411 token,
412 request,
413 })
414}
415
416fn parse_grpc_call_args(
417 command_basename: String,
418 raw_args: &[String],
419) -> Result<GrpcCallSnippet, CmdSnippetError> {
420 let mut config_dir: Option<String> = None;
421 let mut env: Option<String> = None;
422 let mut url: Option<String> = None;
423 let mut token: Option<String> = None;
424
425 let mut args: Vec<String> = raw_args.to_vec();
426 if let Some(first) = args.first().cloned()
427 && !first.starts_with('-')
428 && first != "--"
429 {
430 if first == "call" {
431 args.remove(0);
432 } else if matches!(first.as_str(), "history" | "report") {
433 return Err(CmdSnippetError::UnsupportedSubcommand { subcommand: first });
434 }
435 }
436
437 let mut positional: Vec<String> = Vec::new();
438 let mut i: usize = 0;
439 while i < args.len() {
440 let arg = args[i].as_str();
441 if arg == "--" {
442 positional.extend(args[i + 1..].iter().cloned());
443 break;
444 }
445
446 if arg == "--no-history" {
447 i += 1;
448 continue;
449 }
450
451 if let Some(v) = flag_value_eq(arg, "--config-dir") {
452 config_dir = Some(v?);
453 i += 1;
454 continue;
455 }
456 if arg == "--config-dir" {
457 config_dir = Some(take_value(&args, i, "--config-dir")?);
458 i += 2;
459 continue;
460 }
461
462 if let Some(v) = flag_value_eq(arg, "--env") {
463 env = Some(v?);
464 i += 1;
465 continue;
466 }
467 if arg == "--env" || arg == "-e" {
468 env = Some(take_value(&args, i, arg)?);
469 i += 2;
470 continue;
471 }
472
473 if let Some(v) = flag_value_eq(arg, "--url") {
474 url = Some(v?);
475 i += 1;
476 continue;
477 }
478 if arg == "--url" || arg == "-u" {
479 url = Some(take_value(&args, i, arg)?);
480 i += 2;
481 continue;
482 }
483
484 if let Some(v) = flag_value_eq(arg, "--token") {
485 token = Some(v?);
486 i += 1;
487 continue;
488 }
489 if arg == "--token" {
490 token = Some(take_value(&args, i, "--token")?);
491 i += 2;
492 continue;
493 }
494
495 if arg.starts_with('-') {
496 return Err(CmdSnippetError::UnknownFlag {
497 flag: arg.to_string(),
498 });
499 }
500
501 positional.push(arg.to_string());
502 i += 1;
503 }
504
505 let request = positional
506 .first()
507 .cloned()
508 .ok_or(CmdSnippetError::MissingGrpcRequest)?;
509 if let Some(extra) = positional.get(1) {
510 return Err(CmdSnippetError::UnexpectedArg { arg: extra.clone() });
511 }
512
513 Ok(GrpcCallSnippet {
514 command_basename,
515 config_dir,
516 env,
517 url,
518 token,
519 request,
520 })
521}
522
523fn tokenize_call_snippet(snippet: &str) -> Result<Vec<String>, CmdSnippetError> {
524 let raw = snippet.trim();
525 if raw.is_empty() {
526 return Err(CmdSnippetError::EmptySnippet);
527 }
528
529 let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
530 let continued = remove_line_continuations(&normalized);
531 let expanded = expand_env_vars_best_effort(&continued);
532 let expanded = expanded.replace('\n', " ");
533
534 let mut tokens =
535 shell_words::split(&expanded).map_err(|err| CmdSnippetError::TokenizeFailed {
536 message: err.to_string(),
537 })?;
538
539 if let Some(pipe_idx) = tokens.iter().position(|t| t == "|") {
540 tokens.truncate(pipe_idx);
541 }
542
543 Ok(tokens)
544}
545
546fn remove_line_continuations(s: &str) -> String {
547 let mut out = String::with_capacity(s.len());
548 let mut chars = s.chars().peekable();
549 while let Some(ch) = chars.next() {
550 if ch == '\\' && matches!(chars.peek(), Some('\n')) {
551 let _ = chars.next();
552 continue;
553 }
554 out.push(ch);
555 }
556 out
557}
558
559fn expand_env_vars_best_effort(s: &str) -> String {
560 let mut out = String::with_capacity(s.len());
561 let mut chars = s.chars().peekable();
562 let mut in_single_quote = false;
563 let mut in_double_quote = false;
564
565 while let Some(ch) = chars.next() {
566 match ch {
567 '\'' if !in_double_quote => {
568 in_single_quote = !in_single_quote;
569 out.push(ch);
570 }
571 '"' if !in_single_quote => {
572 in_double_quote = !in_double_quote;
573 out.push(ch);
574 }
575 '\\' => {
576 if matches!(chars.peek(), Some('$')) && !in_single_quote {
577 let _ = chars.next();
578 out.push('$');
579 continue;
580 }
581 out.push(ch);
582 }
583 '$' if !in_single_quote => {
584 if matches!(chars.peek(), Some('{')) {
585 let _ = chars.next();
586 let mut name = String::new();
587 while let Some(&c) = chars.peek() {
588 chars.next();
589 if c == '}' {
590 break;
591 }
592 name.push(c);
593 }
594 if name.is_empty() {
595 out.push('$');
596 out.push_str("{}");
597 continue;
598 }
599 match std::env::var(&name) {
600 Ok(v) => out.push_str(&v),
601 Err(_) => {
602 out.push_str("${");
603 out.push_str(&name);
604 out.push('}');
605 }
606 }
607 continue;
608 }
609
610 let mut name = String::new();
611 while let Some(&c) = chars.peek() {
612 if name.is_empty() {
613 if c.is_ascii_alphabetic() || c == '_' {
614 name.push(c);
615 chars.next();
616 continue;
617 }
618 break;
619 }
620 if c.is_ascii_alphanumeric() || c == '_' {
621 name.push(c);
622 chars.next();
623 continue;
624 }
625 break;
626 }
627
628 if name.is_empty() {
629 out.push('$');
630 continue;
631 }
632
633 match std::env::var(&name) {
634 Ok(v) => out.push_str(&v),
635 Err(_) => {
636 out.push('$');
637 out.push_str(&name);
638 }
639 }
640 }
641 _ => out.push(ch),
642 }
643 }
644
645 out
646}
647
648fn flag_value_eq(arg: &str, flag: &str) -> Option<Result<String, CmdSnippetError>> {
649 arg.strip_prefix(&format!("{flag}=")).map(|v| {
650 if v.is_empty() {
651 Err(CmdSnippetError::MissingFlagValue {
652 flag: flag.to_string(),
653 })
654 } else {
655 Ok(v.to_string())
656 }
657 })
658}
659
660fn take_value(args: &[String], idx: usize, flag: &str) -> Result<String, CmdSnippetError> {
661 args.get(idx + 1)
662 .cloned()
663 .ok_or_else(|| CmdSnippetError::MissingFlagValue {
664 flag: flag.to_string(),
665 })
666}
667
668fn basename(path: &str) -> String {
669 let p = Path::new(path);
670 p.file_name()
671 .map(|s| s.to_string_lossy().to_string())
672 .unwrap_or_else(|| path.to_string())
673}
674
675fn stem_for_operation(path: &str) -> String {
676 let name = basename(path);
677 if let Some(stem) = name.strip_suffix(".graphql") {
678 return stem.to_string();
679 }
680 Path::new(&name)
681 .file_stem()
682 .map(|s| s.to_string_lossy().to_string())
683 .unwrap_or(name)
684}
685
686fn stem_for_request(path: &str) -> String {
687 let name = basename(path);
688 if let Some(stem) = name.strip_suffix(".request.json") {
689 return stem.to_string();
690 }
691 if let Some(stem) = name.strip_suffix(".grpc.json") {
692 return stem.to_string();
693 }
694 Path::new(&name)
695 .file_stem()
696 .map(|s| s.to_string_lossy().to_string())
697 .unwrap_or(name)
698}
699
700fn derive_graphql_case_name(s: &GraphqlCallSnippet) -> String {
701 let stem = stem_for_operation(&s.operation);
702 let stem = if stem.trim().is_empty() {
703 "case".to_string()
704 } else {
705 stem
706 };
707
708 let env_or_url = s.url.as_deref().or(s.env.as_deref()).unwrap_or("implicit");
709 let mut meta: Vec<String> = vec![env_or_url.to_string()];
710 if let Some(jwt) = s.jwt.as_deref() {
711 meta.push(format!("jwt:{jwt}"));
712 }
713
714 format!("{stem} ({})", meta.join(", "))
715}
716
717fn derive_rest_case_name(s: &RestCallSnippet) -> String {
718 let stem = stem_for_request(&s.request);
719 let stem = if stem.trim().is_empty() {
720 "case".to_string()
721 } else {
722 stem
723 };
724
725 let env_or_url = s.url.as_deref().or(s.env.as_deref()).unwrap_or("implicit");
726 let mut meta: Vec<String> = vec![env_or_url.to_string()];
727 if let Some(token) = s.token.as_deref() {
728 meta.push(format!("token:{token}"));
729 }
730
731 format!("{stem} ({})", meta.join(", "))
732}
733
734fn derive_grpc_case_name(s: &GrpcCallSnippet) -> String {
735 let stem = stem_for_request(&s.request);
736 let stem = if stem.trim().is_empty() {
737 "case".to_string()
738 } else {
739 stem
740 };
741
742 let env_or_url = s.url.as_deref().or(s.env.as_deref()).unwrap_or("implicit");
743 let mut meta: Vec<String> = vec![env_or_url.to_string()];
744 if let Some(token) = s.token.as_deref() {
745 meta.push(format!("token:{token}"));
746 }
747
748 format!("{stem} ({})", meta.join(", "))
749}
750
751#[cfg(test)]
752mod tests {
753 use std::sync::Mutex;
754
755 use super::*;
756 use pretty_assertions::assert_eq;
757
758 static ENV_LOCK: Mutex<()> = Mutex::new(());
759
760 #[test]
761 fn tokenization_truncates_at_first_pipe() {
762 let s = "api-gql call --env staging op.graphql | jq .";
763 let tokens = tokenize_call_snippet(s).expect("tokens");
764 assert_eq!(
765 tokens,
766 vec!["api-gql", "call", "--env", "staging", "op.graphql"]
767 );
768 }
769
770 #[test]
771 fn tokenization_removes_backslash_newline() {
772 let s = "api-gql call --env staging \\\n op.graphql";
773 let tokens = tokenize_call_snippet(s).expect("tokens");
774 assert_eq!(
775 tokens,
776 vec!["api-gql", "call", "--env", "staging", "op.graphql"]
777 );
778 }
779
780 #[test]
781 fn tokenization_expands_env_vars_best_effort() {
782 let _g = ENV_LOCK.lock().expect("lock");
783 let key = "NILS_TEST_HOME";
784 let prev = std::env::var(key).ok();
785 unsafe { std::env::set_var(key, "/tmp/nils-test-home") };
787
788 let s = "$NILS_TEST_HOME/bin/api-gql call --env staging op.graphql";
789 let tokens = tokenize_call_snippet(s).expect("tokens");
790 assert_eq!(
791 tokens,
792 vec![
793 "/tmp/nils-test-home/bin/api-gql",
794 "call",
795 "--env",
796 "staging",
797 "op.graphql"
798 ]
799 );
800
801 if let Some(v) = prev {
802 unsafe { std::env::set_var(key, v) };
804 } else {
805 unsafe { std::env::remove_var(key) };
807 }
808 }
809
810 #[test]
811 fn parses_graphql_call_and_ignores_command_path_prefix() {
812 let s = "/usr/local/bin/api-gql call --env staging --jwt service setup/graphql/operations/health.graphql";
813 let parsed = parse_call_snippet(s).expect("parse");
814 let CmdSnippet::Graphql(gql) = parsed else {
815 panic!("expected graphql");
816 };
817 assert_eq!(gql.command_basename, "api-gql");
818 assert_eq!(gql.env.as_deref(), Some("staging"));
819 assert_eq!(gql.jwt.as_deref(), Some("service"));
820 assert_eq!(
821 gql.operation,
822 "setup/graphql/operations/health.graphql".to_string()
823 );
824 }
825
826 #[test]
827 fn graphql_missing_operation_is_error() {
828 let s = "api-gql call --env staging";
829 let err = parse_call_snippet(s).expect_err("expected err");
830 assert!(matches!(err, CmdSnippetError::MissingGraphqlOperation));
831 }
832
833 #[test]
834 fn graphql_case_is_derived_from_op_and_meta() {
835 let s = "api-gql call --env staging --jwt service setup/graphql/operations/health.graphql";
836 let ReportFromCmd::Graphql(report) = parse_report_from_cmd_snippet(s).expect("parse")
837 else {
838 panic!("expected graphql");
839 };
840 assert_eq!(report.case, "health (staging, jwt:service)");
841 }
842
843 fn assert_missing_flag_value(snippet: &str, expected_flag: &str) {
844 let err = parse_call_snippet(snippet).expect_err("expected err");
845 match err {
846 CmdSnippetError::MissingFlagValue { flag } => assert_eq!(flag, expected_flag),
847 _ => panic!("expected missing flag value error"),
848 }
849 }
850
851 #[test]
852 fn graphql_empty_flag_values_are_errors() {
853 let cases = [
854 ("--env=", "--env"),
855 ("--url=", "--url"),
856 ("--jwt=", "--jwt"),
857 ("--config-dir=", "--config-dir"),
858 ];
859 for (flag, expected) in cases {
860 let s = format!("api-gql call {flag} setup/graphql/operations/health.graphql");
861 assert_missing_flag_value(&s, expected);
862 }
863 }
864
865 #[test]
866 fn rest_missing_request_is_error() {
867 let s = "api-rest call --env staging";
868 let err = parse_call_snippet(s).expect_err("expected err");
869 assert!(matches!(err, CmdSnippetError::MissingRestRequest));
870 }
871
872 #[test]
873 fn rest_case_is_derived_from_request_and_meta() {
874 let s =
875 "api-rest call --env staging --token service setup/rest/requests/health.request.json";
876 let ReportFromCmd::Rest(report) = parse_report_from_cmd_snippet(s).expect("parse") else {
877 panic!("expected rest");
878 };
879 assert_eq!(report.case, "health (staging, token:service)");
880 }
881
882 #[test]
883 fn rest_empty_flag_values_are_errors() {
884 let cases = [
885 ("--env=", "--env"),
886 ("--url=", "--url"),
887 ("--token=", "--token"),
888 ];
889 for (flag, expected) in cases {
890 let s = format!("api-rest call {flag} setup/rest/requests/health.request.json");
891 assert_missing_flag_value(&s, expected);
892 }
893 }
894
895 #[test]
896 fn grpc_missing_request_is_error() {
897 let s = "api-grpc call --env staging";
898 let err = parse_call_snippet(s).expect_err("expected err");
899 assert!(matches!(err, CmdSnippetError::MissingGrpcRequest));
900 }
901
902 #[test]
903 fn grpc_case_is_derived_from_request_and_meta() {
904 let s = "api-grpc call --env staging --token service setup/grpc/requests/health.grpc.json";
905 let ReportFromCmd::Grpc(report) = parse_report_from_cmd_snippet(s).expect("parse") else {
906 panic!("expected grpc");
907 };
908 assert_eq!(report.case, "health (staging, token:service)");
909 }
910}