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