Skip to main content

api_testing_core/
cmd_snippet.rs

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 super::*;
938    use nils_test_support::{EnvGuard, GlobalStateLock};
939    use pretty_assertions::assert_eq;
940
941    #[test]
942    fn tokenization_truncates_at_first_pipe() {
943        let s = "api-gql call --env staging op.graphql | jq .";
944        let tokens = tokenize_call_snippet(s).expect("tokens");
945        assert_eq!(
946            tokens,
947            vec!["api-gql", "call", "--env", "staging", "op.graphql"]
948        );
949    }
950
951    #[test]
952    fn tokenization_removes_backslash_newline() {
953        let s = "api-gql call --env staging \\\n op.graphql";
954        let tokens = tokenize_call_snippet(s).expect("tokens");
955        assert_eq!(
956            tokens,
957            vec!["api-gql", "call", "--env", "staging", "op.graphql"]
958        );
959    }
960
961    #[test]
962    fn tokenization_expands_env_vars_best_effort() {
963        let lock = GlobalStateLock::new();
964        let key = "NILS_TEST_HOME";
965        let _guard = EnvGuard::set(&lock, key, "/tmp/nils-test-home");
966
967        let s = "$NILS_TEST_HOME/bin/api-gql call --env staging op.graphql";
968        let tokens = tokenize_call_snippet(s).expect("tokens");
969        assert_eq!(
970            tokens,
971            vec![
972                "/tmp/nils-test-home/bin/api-gql",
973                "call",
974                "--env",
975                "staging",
976                "op.graphql"
977            ]
978        );
979    }
980
981    #[test]
982    fn parses_graphql_call_and_ignores_command_path_prefix() {
983        let s = "/usr/local/bin/api-gql call --env staging --jwt service setup/graphql/operations/health.graphql";
984        let parsed = parse_call_snippet(s).expect("parse");
985        let CmdSnippet::Graphql(gql) = parsed else {
986            panic!("expected graphql");
987        };
988        assert_eq!(gql.command_basename, "api-gql");
989        assert_eq!(gql.env.as_deref(), Some("staging"));
990        assert_eq!(gql.jwt.as_deref(), Some("service"));
991        assert_eq!(
992            gql.operation,
993            "setup/graphql/operations/health.graphql".to_string()
994        );
995    }
996
997    #[test]
998    fn graphql_missing_operation_is_error() {
999        let s = "api-gql call --env staging";
1000        let err = parse_call_snippet(s).expect_err("expected err");
1001        assert!(matches!(err, CmdSnippetError::MissingGraphqlOperation));
1002    }
1003
1004    #[test]
1005    fn graphql_case_is_derived_from_op_and_meta() {
1006        let s = "api-gql call --env staging --jwt service setup/graphql/operations/health.graphql";
1007        let ReportFromCmd::Graphql(report) = parse_report_from_cmd_snippet(s).expect("parse")
1008        else {
1009            panic!("expected graphql");
1010        };
1011        assert_eq!(report.case, "health (staging, jwt:service)");
1012    }
1013
1014    fn assert_missing_flag_value(snippet: &str, expected_flag: &str) {
1015        let err = parse_call_snippet(snippet).expect_err("expected err");
1016        match err {
1017            CmdSnippetError::MissingFlagValue { flag } => assert_eq!(flag, expected_flag),
1018            _ => panic!("expected missing flag value error"),
1019        }
1020    }
1021
1022    #[test]
1023    fn graphql_empty_flag_values_are_errors() {
1024        let cases = [
1025            ("--env=", "--env"),
1026            ("--url=", "--url"),
1027            ("--jwt=", "--jwt"),
1028            ("--config-dir=", "--config-dir"),
1029        ];
1030        for (flag, expected) in cases {
1031            let s = format!("api-gql call {flag} setup/graphql/operations/health.graphql");
1032            assert_missing_flag_value(&s, expected);
1033        }
1034    }
1035
1036    #[test]
1037    fn rest_missing_request_is_error() {
1038        let s = "api-rest call --env staging";
1039        let err = parse_call_snippet(s).expect_err("expected err");
1040        assert!(matches!(err, CmdSnippetError::MissingRestRequest));
1041    }
1042
1043    #[test]
1044    fn rest_case_is_derived_from_request_and_meta() {
1045        let s =
1046            "api-rest call --env staging --token service setup/rest/requests/health.request.json";
1047        let ReportFromCmd::Rest(report) = parse_report_from_cmd_snippet(s).expect("parse") else {
1048            panic!("expected rest");
1049        };
1050        assert_eq!(report.case, "health (staging, token:service)");
1051    }
1052
1053    #[test]
1054    fn rest_empty_flag_values_are_errors() {
1055        let cases = [
1056            ("--env=", "--env"),
1057            ("--url=", "--url"),
1058            ("--token=", "--token"),
1059        ];
1060        for (flag, expected) in cases {
1061            let s = format!("api-rest call {flag} setup/rest/requests/health.request.json");
1062            assert_missing_flag_value(&s, expected);
1063        }
1064    }
1065
1066    #[test]
1067    fn grpc_missing_request_is_error() {
1068        let s = "api-grpc call --env staging";
1069        let err = parse_call_snippet(s).expect_err("expected err");
1070        assert!(matches!(err, CmdSnippetError::MissingGrpcRequest));
1071    }
1072
1073    #[test]
1074    fn grpc_case_is_derived_from_request_and_meta() {
1075        let s = "api-grpc call --env staging --token service setup/grpc/requests/health.grpc.json";
1076        let ReportFromCmd::Grpc(report) = parse_report_from_cmd_snippet(s).expect("parse") else {
1077            panic!("expected grpc");
1078        };
1079        assert_eq!(report.case, "health (staging, token:service)");
1080    }
1081
1082    #[test]
1083    fn websocket_missing_request_is_error() {
1084        let s = "api-websocket call --env staging";
1085        let err = parse_call_snippet(s).expect_err("expected err");
1086        assert!(matches!(err, CmdSnippetError::MissingWebsocketRequest));
1087    }
1088
1089    #[test]
1090    fn websocket_case_is_derived_from_request_and_meta() {
1091        let s = "api-websocket call --env staging --token service setup/websocket/requests/health.ws.json";
1092        let ReportFromCmd::Websocket(report) = parse_report_from_cmd_snippet(s).expect("parse")
1093        else {
1094            panic!("expected websocket");
1095        };
1096        assert_eq!(report.case, "health (staging, token:service)");
1097    }
1098
1099    #[test]
1100    fn websocket_empty_flag_values_are_errors() {
1101        let cases = [
1102            ("--env=", "--env"),
1103            ("--url=", "--url"),
1104            ("--token=", "--token"),
1105        ];
1106        for (flag, expected) in cases {
1107            let s = format!("api-websocket call {flag} setup/websocket/requests/health.ws.json");
1108            assert_missing_flag_value(&s, expected);
1109        }
1110    }
1111}