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