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 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 unsafe { std::env::set_var(key, v) };
636 } else {
637 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}