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}
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        // SAFETY: tests mutate process env while guarded by ENV_LOCK.
786        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            // SAFETY: tests restore process env while guarded by ENV_LOCK.
803            unsafe { std::env::set_var(key, v) };
804        } else {
805            // SAFETY: tests restore process env while guarded by ENV_LOCK.
806            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}