1use crate::error::{ObserverError, ObserverResult};
5use serde::{Deserialize, Serialize};
6
7pub const DEFAULT_RUN_TIMEOUT_MS: u32 = 1_000;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct SuiteCore {
11 pub items: Vec<SuiteItem>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct SuiteItem {
16 pub item_id: String,
17 pub selection_mode: SelectionMode,
18 pub case_source: CaseSource,
19 pub case_binding: String,
20 pub body: Vec<Statement>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "k", rename_all = "snake_case")]
25pub enum CaseSource {
26 Inventory { selector: Selector },
27 Files {
28 root: String,
29 glob: String,
30 key_field: CaseKeyField,
31 },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum SelectionMode {
37 Required,
38 Optional,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "k", rename_all = "snake_case")]
43pub enum Selector {
44 Exact { value: String },
45 Prefix { value: String },
46 Glob { value: String },
47 Regex { value: String },
48 Any { items: Vec<Selector> },
49 All { items: Vec<Selector> },
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum CaseKeyField {
55 Path,
56 Name,
57 Stem,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "k", rename_all = "snake_case")]
62pub enum Statement {
63 Assert { predicate: Predicate },
64 Publish {
65 name: String,
66 artifact_kind: String,
67 path: ValueExpr,
68 },
69 ResultBranch {
70 result: ResultExpr,
71 ok_binding: String,
72 ok: Vec<Statement>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 fail_binding: Option<String>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 fail: Vec<Statement>,
77 },
78 BoolBranch {
79 predicate: Predicate,
80 if_true: Vec<Statement>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 if_false: Vec<Statement>,
83 },
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "k", rename_all = "snake_case")]
88pub enum ResultExpr {
89 Run { test: ValueExpr, timeout_ms: u32 },
90 Proc {
91 path: ValueExpr,
92 args: Vec<ValueExpr>,
93 timeout_ms: u32,
94 },
95 HttpGet { url: ValueExpr, timeout_ms: u32 },
96 Tcp {
97 address: ValueExpr,
98 send: ValueExpr,
99 recv_max: u32,
100 timeout_ms: u32,
101 },
102 ArtifactCheck {
103 name: String,
104 artifact_kind: String,
105 },
106 ExtractJson {
107 name: String,
108 select: String,
109 },
110 ExtractJsonl {
111 name: String,
112 select: String,
113 },
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "k", rename_all = "snake_case")]
118pub enum Predicate {
119 Compare {
120 op: CompareOp,
121 left: ValueExpr,
122 right: ValueExpr,
123 },
124 IsStatus {
125 left: ValueExpr,
126 status: i64,
127 },
128 IsStatusClass {
129 left: ValueExpr,
130 class: i64,
131 },
132 HasHeader {
133 left: ValueExpr,
134 name: String,
135 },
136 Contains {
137 left: ValueExpr,
138 right: ValueExpr,
139 },
140 ContainsRegex {
141 left: ValueExpr,
142 regex: String,
143 },
144 StartsWith {
145 left: ValueExpr,
146 right: ValueExpr,
147 },
148 EndsWith {
149 left: ValueExpr,
150 right: ValueExpr,
151 },
152 Match {
153 left: ValueExpr,
154 regex: String,
155 },
156 Fail {
157 msg: String,
158 },
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum CompareOp {
164 Eq,
165 Ne,
166 Lt,
167 Le,
168 Gt,
169 Ge,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "k", rename_all = "snake_case")]
174pub enum ValueExpr {
175 Binding { name: String },
176 String { value: String },
177 BytesUtf8 { value: String },
178 Int { value: i64 },
179 Field { base: Box<ValueExpr>, name: String },
180 Header { base: Box<ValueExpr>, name: String },
181 ArtifactPath { name: String },
182 JoinPath { parts: Vec<ValueExpr> },
183}
184
185impl SuiteCore {
186 pub fn parse_simple(source: &str) -> ObserverResult<Self> {
187 SimpleParser::new(source).parse()
188 }
189
190 pub fn parse_full(source: &str) -> ObserverResult<Self> {
191 FullParser::new(source).parse()
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196enum Token {
197 Ident(String),
198 String(String),
199 Regex(String),
200 Int(i64),
201 LParen,
202 RParen,
203 Colon,
204 Dot,
205 LBracket,
206 RBracket,
207 Pipe,
208 Comma,
209 Hash,
210 Eq,
211 Ne,
212 Lt,
213 Le,
214 Gt,
215 Ge,
216}
217
218struct SimpleParser {
219 tokens: Vec<Token>,
220 position: usize,
221 next_item_ix: usize,
222}
223
224impl SimpleParser {
225 fn new(source: &str) -> Self {
226 Self {
227 tokens: tokenize(source),
228 position: 0,
229 next_item_ix: 1,
230 }
231 }
232
233 fn parse(mut self) -> ObserverResult<SuiteCore> {
234 let mut items = Vec::new();
235 while !self.is_at_end() {
236 items.push(self.parse_item()?);
237 }
238 Ok(SuiteCore { items })
239 }
240
241 fn parse_item(&mut self) -> ObserverResult<SuiteItem> {
242 if self.peek_ident("module") {
243 return Err(ObserverError::SuiteParse(
244 "`module` is only valid in full surface; parse this suite with `--surface full`".to_owned(),
245 ));
246 }
247 if self.peek_token(&Token::LParen) {
248 return Err(ObserverError::SuiteParse(
249 "inventory or workflow iteration forms like `(prefix: ...) forEach: ...` are only valid in full surface; parse this suite with `--surface full`".to_owned(),
250 ));
251 }
252 if matches!(self.tokens.get(self.position), Some(Token::Ident(value)) if value != "test") {
253 return Err(ObserverError::SuiteParse(format!(
254 "simple surface expects `test ...`; found `{}`",
255 self.preview_ident()
256 )));
257 }
258 self.expect_ident("test")?;
259 let selector = self.parse_selector()?;
260
261 let mut timeout_ms = DEFAULT_RUN_TIMEOUT_MS;
262 if self.peek_ident("timeoutMs") {
263 self.advance();
264 self.expect(Token::Colon)?;
265 let value = self.expect_int()?;
266 timeout_ms = u32::try_from(value)
267 .map_err(|_| ObserverError::SuiteParse("timeoutMs must fit in u32".to_owned()))?;
268 }
269
270 let selection_mode = if self.peek_ident("optional") {
271 self.advance();
272 SelectionMode::Optional
273 } else {
274 SelectionMode::Required
275 };
276
277 self.expect(Token::Colon)?;
278 let assertions = if self.peek_token(&Token::LBracket) {
279 self.advance();
280 let mut statements = Vec::new();
281 while !self.peek_token(&Token::RBracket) {
282 statements.push(Statement::Assert {
283 predicate: self.parse_simple_expect()?,
284 });
285 }
286 self.expect(Token::RBracket)?;
287 self.expect(Token::Dot)?;
288 statements
289 } else {
290 vec![Statement::Assert {
291 predicate: self.parse_simple_expect()?,
292 }]
293 };
294
295 let selection_binding = "b1".to_owned();
296 let run_binding = "b2".to_owned();
297 let body = vec![Statement::ResultBranch {
298 result: ResultExpr::Run {
299 test: ValueExpr::Binding {
300 name: selection_binding.clone(),
301 },
302 timeout_ms,
303 },
304 ok_binding: run_binding,
305 ok: assertions,
306 fail_binding: None,
307 fail: Vec::new(),
308 }];
309
310 let item = SuiteItem {
311 item_id: format!("item-{}", self.next_item_ix),
312 selection_mode,
313 case_source: CaseSource::Inventory { selector },
314 case_binding: selection_binding,
315 body,
316 };
317 self.next_item_ix += 1;
318 Ok(item)
319 }
320
321 fn parse_selector(&mut self) -> ObserverResult<Selector> {
322 match self.advance() {
323 Some(Token::String(value)) => Ok(Selector::Exact { value }),
324 Some(Token::Ident(kind)) if kind == "prefix" => {
325 self.expect(Token::Colon)?;
326 Ok(Selector::Prefix {
327 value: self.expect_string()?,
328 })
329 }
330 Some(Token::Ident(kind)) if kind == "glob" => {
331 self.expect(Token::Colon)?;
332 Ok(Selector::Glob {
333 value: self.expect_string()?,
334 })
335 }
336 Some(Token::Ident(kind)) if kind == "regex" => {
337 self.expect(Token::Colon)?;
338 Ok(Selector::Regex {
339 value: self.expect_regex()?,
340 })
341 }
342 Some(token) => Err(ObserverError::SuiteParse(format!(
343 "unexpected selector token: {token:?}"
344 ))),
345 None => Err(ObserverError::SuiteParse(
346 "unexpected end of simple suite".to_owned(),
347 )),
348 }
349 }
350
351 fn preview_ident(&self) -> String {
352 match self.tokens.get(self.position) {
353 Some(Token::Ident(value)) => value.clone(),
354 _ => "<unknown>".to_owned(),
355 }
356 }
357
358 fn parse_simple_expect(&mut self) -> ObserverResult<Predicate> {
359 self.expect_ident("expect")?;
360 let field_name = self.expect_ident_value()?;
361 let left = ValueExpr::Field {
362 base: Box::new(ValueExpr::Binding {
363 name: "b2".to_owned(),
364 }),
365 name: field_name.clone(),
366 };
367
368 let predicate = match self.advance() {
369 Some(Token::Eq) => Predicate::Compare {
370 op: CompareOp::Eq,
371 left,
372 right: self.parse_simple_value(&field_name)?,
373 },
374 Some(Token::Ne) => Predicate::Compare {
375 op: CompareOp::Ne,
376 left,
377 right: self.parse_simple_value(&field_name)?,
378 },
379 Some(Token::Lt) => Predicate::Compare {
380 op: CompareOp::Lt,
381 left,
382 right: self.parse_simple_value(&field_name)?,
383 },
384 Some(Token::Le) => Predicate::Compare {
385 op: CompareOp::Le,
386 left,
387 right: self.parse_simple_value(&field_name)?,
388 },
389 Some(Token::Gt) => Predicate::Compare {
390 op: CompareOp::Gt,
391 left,
392 right: self.parse_simple_value(&field_name)?,
393 },
394 Some(Token::Ge) => Predicate::Compare {
395 op: CompareOp::Ge,
396 left,
397 right: self.parse_simple_value(&field_name)?,
398 },
399 Some(Token::Ident(kind)) if kind == "contains" => Predicate::Contains {
400 left,
401 right: ValueExpr::String {
402 value: self.expect_string()?,
403 },
404 },
405 Some(Token::Ident(kind)) if kind == "match" => Predicate::Match {
406 left,
407 regex: self.expect_regex()?,
408 },
409 Some(token) => {
410 return Err(ObserverError::SuiteParse(format!(
411 "unexpected predicate operator: {token:?}"
412 )))
413 }
414 None => {
415 return Err(ObserverError::SuiteParse(
416 "unexpected end of simple suite".to_owned(),
417 ))
418 }
419 };
420
421 self.expect(Token::Dot)?;
422 Ok(predicate)
423 }
424
425 fn parse_simple_value(&mut self, field_name: &str) -> ObserverResult<ValueExpr> {
426 match field_name {
427 "exit" => Ok(ValueExpr::Int {
428 value: self.expect_int()?,
429 }),
430 "out" | "err" => Ok(ValueExpr::String {
431 value: self.expect_string()?,
432 }),
433 _ => Err(ObserverError::SuiteParse(format!(
434 "unsupported simple field `{field_name}`"
435 ))),
436 }
437 }
438
439 fn expect_ident(&mut self, expected: &str) -> ObserverResult<()> {
440 let actual = self.expect_ident_value()?;
441 if actual == expected {
442 Ok(())
443 } else {
444 Err(ObserverError::SuiteParse(format!(
445 "expected `{expected}`, found `{actual}`"
446 )))
447 }
448 }
449
450 fn expect_ident_value(&mut self) -> ObserverResult<String> {
451 match self.advance() {
452 Some(Token::Ident(value)) => Ok(value),
453 Some(token) => Err(ObserverError::SuiteParse(format!(
454 "expected identifier, found {token:?}"
455 ))),
456 None => Err(ObserverError::SuiteParse(
457 "unexpected end of simple suite".to_owned(),
458 )),
459 }
460 }
461
462 fn expect_string(&mut self) -> ObserverResult<String> {
463 match self.advance() {
464 Some(Token::String(value)) => Ok(value),
465 Some(token) => Err(ObserverError::SuiteParse(format!(
466 "expected string literal, found {token:?}"
467 ))),
468 None => Err(ObserverError::SuiteParse(
469 "unexpected end of simple suite".to_owned(),
470 )),
471 }
472 }
473
474 fn expect_regex(&mut self) -> ObserverResult<String> {
475 match self.advance() {
476 Some(Token::Regex(value)) => Ok(value),
477 Some(token) => Err(ObserverError::SuiteParse(format!(
478 "expected regex literal, found {token:?}"
479 ))),
480 None => Err(ObserverError::SuiteParse(
481 "unexpected end of simple suite".to_owned(),
482 )),
483 }
484 }
485
486 fn expect_int(&mut self) -> ObserverResult<i64> {
487 match self.advance() {
488 Some(Token::Int(value)) => Ok(value),
489 Some(token) => Err(ObserverError::SuiteParse(format!(
490 "expected integer literal, found {token:?}"
491 ))),
492 None => Err(ObserverError::SuiteParse(
493 "unexpected end of simple suite".to_owned(),
494 )),
495 }
496 }
497
498 fn expect(&mut self, expected: Token) -> ObserverResult<()> {
499 match self.advance() {
500 Some(token) if token == expected => Ok(()),
501 Some(token) => Err(ObserverError::SuiteParse(format!(
502 "expected {expected:?}, found {token:?}"
503 ))),
504 None => Err(ObserverError::SuiteParse(
505 "unexpected end of simple suite".to_owned(),
506 )),
507 }
508 }
509
510 fn peek_ident(&self, expected: &str) -> bool {
511 matches!(self.tokens.get(self.position), Some(Token::Ident(value)) if value == expected)
512 }
513
514 fn peek_token(&self, expected: &Token) -> bool {
515 self.tokens.get(self.position) == Some(expected)
516 }
517
518 fn advance(&mut self) -> Option<Token> {
519 let token = self.tokens.get(self.position).cloned();
520 if token.is_some() {
521 self.position += 1;
522 }
523 token
524 }
525
526 fn is_at_end(&self) -> bool {
527 self.position >= self.tokens.len()
528 }
529}
530
531struct FullParser {
532 tokens: Vec<Token>,
533 position: usize,
534 next_item_ix: usize,
535}
536
537#[derive(Debug, Default)]
538struct BindingContext {
539 scopes: Vec<std::collections::BTreeMap<String, String>>,
540 next_binding_ix: usize,
541}
542
543impl BindingContext {
544 fn new() -> Self {
545 Self {
546 scopes: Vec::new(),
547 next_binding_ix: 1,
548 }
549 }
550
551 fn push_scope(&mut self) {
552 self.scopes.push(std::collections::BTreeMap::new());
553 }
554
555 fn pop_scope(&mut self) {
556 self.scopes.pop();
557 }
558
559 fn introduce(&mut self, source_name: &str) -> String {
560 let canonical = format!("b{}", self.next_binding_ix);
561 self.next_binding_ix += 1;
562 if let Some(scope) = self.scopes.last_mut() {
563 scope.insert(source_name.to_owned(), canonical.clone());
564 }
565 canonical
566 }
567
568 fn resolve(&self, source_name: &str) -> Option<String> {
569 for scope in self.scopes.iter().rev() {
570 if let Some(name) = scope.get(source_name) {
571 return Some(name.clone());
572 }
573 }
574 None
575 }
576}
577
578impl FullParser {
579 fn new(source: &str) -> Self {
580 Self {
581 tokens: tokenize(source),
582 position: 0,
583 next_item_ix: 1,
584 }
585 }
586
587 fn parse(mut self) -> ObserverResult<SuiteCore> {
588 if self.peek_ident("module") {
589 self.advance();
590 self.expect_ident_value()?;
591 self.expect(Token::Dot)?;
592 }
593
594 let mut items = Vec::new();
595 while !self.is_at_end() {
596 items.push(self.parse_item()?);
597 }
598 if items.is_empty() {
599 return Err(ObserverError::SuiteParse(
600 "full script must contain at least one suite item".to_owned(),
601 ));
602 }
603 Ok(SuiteCore { items })
604 }
605
606 fn parse_item(&mut self) -> ObserverResult<SuiteItem> {
607 if self.peek_ident("test") {
608 return Err(ObserverError::SuiteParse(
609 "simple surface `test ...` items are not valid in full surface; parse this suite with `--surface simple` or rewrite it as `(selector) forEach: ...`".to_owned(),
610 ));
611 }
612 self.expect(Token::LParen)?;
613 let case_source = self.parse_case_source()?;
614 self.expect(Token::RParen)?;
615
616 let selection_mode = match self.expect_ident_value()?.as_str() {
617 "forEach" | "forEachCase" => SelectionMode::Required,
618 "forEachOptional" | "forEachCaseOptional" => SelectionMode::Optional,
619 actual => {
620 return Err(ObserverError::SuiteParse(format!(
621 "expected `forEach`, `forEachOptional`, `forEachCase`, or `forEachCaseOptional`, found `{actual}`"
622 )))
623 }
624 };
625 self.expect(Token::Colon)?;
626
627 let mut bindings = BindingContext::new();
628 let (case_binding, body) = self.parse_bound_block(&mut bindings)?;
629 self.expect(Token::Dot)?;
630
631 let item = SuiteItem {
632 item_id: format!("item-{}", self.next_item_ix),
633 selection_mode,
634 case_source,
635 case_binding,
636 body,
637 };
638 self.next_item_ix += 1;
639 Ok(item)
640 }
641
642 fn parse_bound_block(&mut self, bindings: &mut BindingContext) -> ObserverResult<(String, Vec<Statement>)> {
643 self.expect(Token::LBracket)?;
644 self.expect(Token::Colon)?;
645 let source_name = self.expect_ident_value()?;
646 self.expect(Token::Pipe)?;
647
648 bindings.push_scope();
649 let canonical = bindings.introduce(&source_name);
650
651 let mut statements = Vec::new();
652 while !self.peek_token(&Token::RBracket) {
653 statements.push(self.parse_statement(bindings)?);
654 }
655 self.expect(Token::RBracket)?;
656 bindings.pop_scope();
657 Ok((canonical, statements))
658 }
659
660 fn parse_plain_block(&mut self, bindings: &mut BindingContext) -> ObserverResult<Vec<Statement>> {
661 self.expect(Token::LBracket)?;
662 let mut statements = Vec::new();
663 while !self.peek_token(&Token::RBracket) {
664 statements.push(self.parse_statement(bindings)?);
665 }
666 self.expect(Token::RBracket)?;
667 Ok(statements)
668 }
669
670 fn parse_statement(&mut self, bindings: &mut BindingContext) -> ObserverResult<Statement> {
671 if self.peek_ident("expect") {
672 self.advance();
673 self.expect(Token::Colon)?;
674 let predicate = self.parse_predicate(bindings)?;
675 self.expect(Token::Dot)?;
676 return Ok(Statement::Assert { predicate });
677 }
678
679 if self.peek_ident("publish") {
680 self.advance();
681 self.expect(Token::Colon)?;
682 let name = self.expect_string()?;
683 self.expect_ident("kind")?;
684 self.expect(Token::Colon)?;
685 let artifact_kind = self.expect_string()?;
686 self.expect_ident("path")?;
687 self.expect(Token::Colon)?;
688 let path = self.parse_value_expr(bindings)?;
689 self.expect(Token::Dot)?;
690 return Ok(Statement::Publish {
691 name,
692 artifact_kind,
693 path,
694 });
695 }
696
697 if self.peek_token(&Token::LParen) {
698 let checkpoint = self.position;
699 self.advance();
700 if let Ok(result) = self.try_parse_result_expr(bindings) {
701 self.expect(Token::RParen)?;
702 self.expect_ident("ifOk")?;
703 self.expect(Token::Colon)?;
704 let (ok_binding, ok) = self.parse_bound_block_with_binding(bindings)?;
705 let (fail_binding, fail) = if self.peek_ident("ifFail") {
706 self.advance();
707 self.expect(Token::Colon)?;
708 let fail_block = self.parse_bound_block_with_binding(bindings)?;
709 (Some(fail_block.0), fail_block.1)
710 } else {
711 (None, Vec::new())
712 };
713 self.expect(Token::Dot)?;
714 return Ok(Statement::ResultBranch {
715 result,
716 ok_binding,
717 ok,
718 fail_binding,
719 fail,
720 });
721 }
722 self.position = checkpoint;
723 self.expect(Token::LParen)?;
724 let predicate = self.parse_predicate(bindings)?;
725 self.expect(Token::RParen)?;
726 self.expect_ident("ifTrue")?;
727 self.expect(Token::Colon)?;
728 let if_true = self.parse_plain_block(bindings)?;
729 let if_false = if self.peek_ident("ifFalse") {
730 self.advance();
731 self.expect(Token::Colon)?;
732 self.parse_plain_block(bindings)?
733 } else {
734 Vec::new()
735 };
736 self.expect(Token::Dot)?;
737 return Ok(Statement::BoolBranch {
738 predicate,
739 if_true,
740 if_false,
741 });
742 }
743
744 Err(ObserverError::SuiteParse(
745 "expected full-script statement".to_owned(),
746 ))
747 }
748
749 fn parse_bound_block_with_binding(
750 &mut self,
751 bindings: &mut BindingContext,
752 ) -> ObserverResult<(String, Vec<Statement>)> {
753 self.expect(Token::LBracket)?;
754 self.expect(Token::Colon)?;
755 let source_name = self.expect_ident_value()?;
756 self.expect(Token::Pipe)?;
757
758 bindings.push_scope();
759 let canonical = bindings.introduce(&source_name);
760 let mut statements = Vec::new();
761 while !self.peek_token(&Token::RBracket) {
762 statements.push(self.parse_statement(bindings)?);
763 }
764 self.expect(Token::RBracket)?;
765 bindings.pop_scope();
766 Ok((canonical, statements))
767 }
768
769 fn try_parse_result_expr(&mut self, bindings: &BindingContext) -> ObserverResult<ResultExpr> {
770 match self.expect_ident_value()?.as_str() {
771 "run" => {
772 self.expect(Token::Colon)?;
773 let test = self.parse_value_expr(bindings)?;
774 self.expect_ident("timeoutMs")?;
775 self.expect(Token::Colon)?;
776 let timeout_ms = self.expect_uint()?;
777 Ok(ResultExpr::Run { test, timeout_ms })
778 }
779 "proc" => {
780 self.expect(Token::Colon)?;
781 let path = self.parse_value_expr(bindings)?;
782 self.expect_ident("args")?;
783 self.expect(Token::Colon)?;
784 let args = self.parse_value_expr_array(bindings)?;
785 self.expect_ident("timeoutMs")?;
786 self.expect(Token::Colon)?;
787 let timeout_ms = self.expect_uint()?;
788 Ok(ResultExpr::Proc {
789 path,
790 args,
791 timeout_ms,
792 })
793 }
794 "httpGet" => {
795 self.expect(Token::Colon)?;
796 let url = self.parse_value_expr(bindings)?;
797 self.expect_ident("timeoutMs")?;
798 self.expect(Token::Colon)?;
799 let timeout_ms = self.expect_uint()?;
800 Ok(ResultExpr::HttpGet { url, timeout_ms })
801 }
802 "tcp" => {
803 self.expect(Token::Colon)?;
804 let address = self.parse_value_expr(bindings)?;
805 self.expect_ident("send")?;
806 self.expect(Token::Colon)?;
807 let send = self.parse_value_expr(bindings)?;
808 self.expect_ident("recvMax")?;
809 self.expect(Token::Colon)?;
810 let recv_max = self.expect_uint()?;
811 self.expect_ident("timeoutMs")?;
812 self.expect(Token::Colon)?;
813 let timeout_ms = self.expect_uint()?;
814 Ok(ResultExpr::Tcp {
815 address,
816 send,
817 recv_max,
818 timeout_ms,
819 })
820 }
821 "artifactCheck" => {
822 self.expect(Token::Colon)?;
823 let name = self.expect_string()?;
824 self.expect_ident("kind")?;
825 self.expect(Token::Colon)?;
826 let artifact_kind = self.expect_string()?;
827 Ok(ResultExpr::ArtifactCheck { name, artifact_kind })
828 }
829 "extractJson" => {
830 self.expect(Token::Colon)?;
831 let name = self.expect_string()?;
832 self.expect_ident("select")?;
833 self.expect(Token::Colon)?;
834 let select = self.expect_string()?;
835 Ok(ResultExpr::ExtractJson { name, select })
836 }
837 "extractJsonl" => {
838 self.expect(Token::Colon)?;
839 let name = self.expect_string()?;
840 self.expect_ident("select")?;
841 self.expect(Token::Colon)?;
842 let select = self.expect_string()?;
843 Ok(ResultExpr::ExtractJsonl { name, select })
844 }
845 actual => Err(ObserverError::SuiteParse(format!(
846 "unknown result expression `{actual}`"
847 ))),
848 }
849 }
850
851 fn parse_predicate(&mut self, bindings: &BindingContext) -> ObserverResult<Predicate> {
852 if self.peek_ident("Fail") {
853 self.advance();
854 self.expect_ident("msg")?;
855 self.expect(Token::Colon)?;
856 return Ok(Predicate::Fail {
857 msg: self.expect_string()?,
858 });
859 }
860
861 let left = self.parse_value_expr(bindings)?;
862 if self.peek_ident("isStatusClass") {
863 self.advance();
864 self.expect(Token::Colon)?;
865 return Ok(Predicate::IsStatusClass {
866 left,
867 class: i64::from(self.expect_uint()?),
868 });
869 }
870 if self.peek_ident("isStatus") {
871 self.advance();
872 self.expect(Token::Colon)?;
873 return Ok(Predicate::IsStatus {
874 left,
875 status: i64::from(self.expect_uint()?),
876 });
877 }
878 if self.peek_ident("hasHeader") {
879 self.advance();
880 self.expect(Token::Colon)?;
881 return Ok(Predicate::HasHeader {
882 left,
883 name: self.expect_string()?,
884 });
885 }
886 if self.peek_ident("contains") {
887 self.advance();
888 self.expect(Token::Colon)?;
889 if let Some(Token::Regex(regex)) = self.advance() {
890 return Ok(Predicate::ContainsRegex { left, regex });
891 }
892 self.position -= 1;
893 return Ok(Predicate::Contains {
894 left,
895 right: self.parse_value_expr(bindings)?,
896 });
897 }
898 if self.peek_ident("startsWith") {
899 self.advance();
900 self.expect(Token::Colon)?;
901 return Ok(Predicate::StartsWith {
902 left,
903 right: self.parse_value_expr(bindings)?,
904 });
905 }
906 if self.peek_ident("endsWith") {
907 self.advance();
908 self.expect(Token::Colon)?;
909 return Ok(Predicate::EndsWith {
910 left,
911 right: self.parse_value_expr(bindings)?,
912 });
913 }
914 if self.peek_ident("match") {
915 self.advance();
916 self.expect(Token::Colon)?;
917 return Ok(Predicate::Match {
918 left,
919 regex: self.expect_regex()?,
920 });
921 }
922
923 let op = match self.advance() {
924 Some(Token::Eq) => CompareOp::Eq,
925 Some(Token::Ne) => CompareOp::Ne,
926 Some(Token::Lt) => CompareOp::Lt,
927 Some(Token::Le) => CompareOp::Le,
928 Some(Token::Gt) => CompareOp::Gt,
929 Some(Token::Ge) => CompareOp::Ge,
930 Some(token) => {
931 return Err(ObserverError::SuiteParse(format!(
932 "expected predicate operator, found {token:?}"
933 )))
934 }
935 None => {
936 return Err(ObserverError::SuiteParse(
937 "unexpected end of full script".to_owned(),
938 ))
939 }
940 };
941 let right = self.parse_value_expr(bindings)?;
942 Ok(Predicate::Compare { op, left, right })
943 }
944
945 fn parse_value_expr(&mut self, bindings: &BindingContext) -> ObserverResult<ValueExpr> {
946 if self.peek_ident("artifactPath") {
947 self.advance();
948 self.expect(Token::Colon)?;
949 return Ok(ValueExpr::ArtifactPath {
950 name: self.expect_string()?,
951 });
952 }
953 if self.peek_ident("joinPath") {
954 self.advance();
955 self.expect(Token::Colon)?;
956 return Ok(ValueExpr::JoinPath {
957 parts: self.parse_value_expr_array(bindings)?,
958 });
959 }
960
961 match self.advance() {
962 Some(Token::Ident(name)) => bindings
963 .resolve(&name)
964 .map(|canonical| ValueExpr::Binding { name: canonical })
965 .ok_or_else(|| {
966 ObserverError::SuiteParse(format!("unbound identifier `{name}`"))
967 }),
968 Some(Token::String(value)) => {
969 if self.peek_token(&Token::Hash) {
970 self.advance();
971 let suffix = self.expect_ident_value()?;
972 if suffix != "utf8" {
973 return Err(ObserverError::SuiteParse(format!(
974 "unsupported byte literal suffix `{suffix}`"
975 )));
976 }
977 Ok(ValueExpr::BytesUtf8 { value })
978 } else {
979 Ok(ValueExpr::String { value })
980 }
981 }
982 Some(Token::Int(value)) => Ok(ValueExpr::Int { value }),
983 Some(Token::LParen) => {
984 let base = self.parse_value_expr(bindings)?;
985 let name = self.expect_ident_value()?;
986 if name == "header" {
987 self.expect(Token::Colon)?;
988 let header_name = self.expect_string()?;
989 self.expect(Token::RParen)?;
990 return Ok(ValueExpr::Header {
991 base: Box::new(base),
992 name: header_name,
993 });
994 }
995 self.expect(Token::RParen)?;
996 Ok(ValueExpr::Field {
997 base: Box::new(base),
998 name,
999 })
1000 }
1001 Some(token) => Err(ObserverError::SuiteParse(format!(
1002 "unexpected value expression token: {token:?}"
1003 ))),
1004 None => Err(ObserverError::SuiteParse(
1005 "unexpected end of full script".to_owned(),
1006 )),
1007 }
1008 }
1009
1010 fn parse_case_source(&mut self) -> ObserverResult<CaseSource> {
1011 if self.peek_ident("files") {
1012 self.advance();
1013 self.expect(Token::Colon)?;
1014 let root = self.expect_string()?;
1015 self.expect_ident("glob")?;
1016 self.expect(Token::Colon)?;
1017 let glob = self.expect_string()?;
1018 self.expect_ident("key")?;
1019 self.expect(Token::Colon)?;
1020 let key_field = match self.expect_ident_value()?.as_str() {
1021 "path" => CaseKeyField::Path,
1022 "name" => CaseKeyField::Name,
1023 "stem" => CaseKeyField::Stem,
1024 other => {
1025 return Err(ObserverError::SuiteParse(format!(
1026 "unsupported workflow key field `{other}`"
1027 )))
1028 }
1029 };
1030 Ok(CaseSource::Files {
1031 root,
1032 glob,
1033 key_field,
1034 })
1035 } else {
1036 Ok(CaseSource::Inventory {
1037 selector: self.parse_selector()?,
1038 })
1039 }
1040 }
1041
1042 fn parse_selector(&mut self) -> ObserverResult<Selector> {
1043 match self.advance() {
1044 Some(Token::String(value)) => Ok(Selector::Exact { value }),
1045 Some(Token::Regex(value)) => Ok(Selector::Regex { value }),
1046 Some(Token::Ident(kind)) if kind == "prefix" => {
1047 self.expect(Token::Colon)?;
1048 Ok(Selector::Prefix {
1049 value: self.expect_string()?,
1050 })
1051 }
1052 Some(Token::Ident(kind)) if kind == "glob" => {
1053 self.expect(Token::Colon)?;
1054 Ok(Selector::Glob {
1055 value: self.expect_string()?,
1056 })
1057 }
1058 Some(Token::Ident(kind)) if kind == "regex" => {
1059 self.expect(Token::Colon)?;
1060 Ok(Selector::Regex {
1061 value: self.expect_regex()?,
1062 })
1063 }
1064 Some(Token::Ident(kind)) if kind == "any" => {
1065 self.expect(Token::Colon)?;
1066 self.expect(Token::LBracket)?;
1067 let items = self.parse_selector_list()?;
1068 self.expect(Token::RBracket)?;
1069 Ok(Selector::Any { items })
1070 }
1071 Some(Token::Ident(kind)) if kind == "all" => {
1072 self.expect(Token::Colon)?;
1073 self.expect(Token::LBracket)?;
1074 let items = self.parse_selector_list()?;
1075 self.expect(Token::RBracket)?;
1076 Ok(Selector::All { items })
1077 }
1078 Some(token) => Err(ObserverError::SuiteParse(format!(
1079 "unexpected selector token: {token:?}"
1080 ))),
1081 None => Err(ObserverError::SuiteParse(
1082 "unexpected end of full script".to_owned(),
1083 )),
1084 }
1085 }
1086
1087 fn parse_selector_list(&mut self) -> ObserverResult<Vec<Selector>> {
1088 let mut items = Vec::new();
1089 if self.peek_token(&Token::RBracket) {
1090 return Ok(items);
1091 }
1092 loop {
1093 items.push(self.parse_selector()?);
1094 if self.peek_token(&Token::Comma) {
1095 self.advance();
1096 } else {
1097 break;
1098 }
1099 }
1100 Ok(items)
1101 }
1102
1103 fn parse_value_expr_array(&mut self, bindings: &BindingContext) -> ObserverResult<Vec<ValueExpr>> {
1104 self.expect(Token::LBracket)?;
1105 let mut values = Vec::new();
1106 if self.peek_token(&Token::RBracket) {
1107 self.advance();
1108 return Ok(values);
1109 }
1110 loop {
1111 values.push(self.parse_value_expr(bindings)?);
1112 if self.peek_token(&Token::Comma) {
1113 self.advance();
1114 } else {
1115 break;
1116 }
1117 }
1118 self.expect(Token::RBracket)?;
1119 Ok(values)
1120 }
1121
1122 fn expect_ident(&mut self, expected: &str) -> ObserverResult<()> {
1123 let actual = self.expect_ident_value()?;
1124 if actual == expected {
1125 Ok(())
1126 } else {
1127 Err(ObserverError::SuiteParse(format!(
1128 "expected `{expected}`, found `{actual}`"
1129 )))
1130 }
1131 }
1132
1133 fn expect_ident_value(&mut self) -> ObserverResult<String> {
1134 match self.advance() {
1135 Some(Token::Ident(value)) => Ok(value),
1136 Some(token) => Err(ObserverError::SuiteParse(format!(
1137 "expected identifier, found {token:?}"
1138 ))),
1139 None => Err(ObserverError::SuiteParse(
1140 "unexpected end of full script".to_owned(),
1141 )),
1142 }
1143 }
1144
1145 fn expect_string(&mut self) -> ObserverResult<String> {
1146 match self.advance() {
1147 Some(Token::String(value)) => Ok(value),
1148 Some(token) => Err(ObserverError::SuiteParse(format!(
1149 "expected string literal, found {token:?}"
1150 ))),
1151 None => Err(ObserverError::SuiteParse(
1152 "unexpected end of full script".to_owned(),
1153 )),
1154 }
1155 }
1156
1157 fn expect_regex(&mut self) -> ObserverResult<String> {
1158 match self.advance() {
1159 Some(Token::Regex(value)) => Ok(value),
1160 Some(token) => Err(ObserverError::SuiteParse(format!(
1161 "expected regex literal, found {token:?}"
1162 ))),
1163 None => Err(ObserverError::SuiteParse(
1164 "unexpected end of full script".to_owned(),
1165 )),
1166 }
1167 }
1168
1169 fn expect_uint(&mut self) -> ObserverResult<u32> {
1170 match self.advance() {
1171 Some(Token::Int(value)) if value >= 0 => u32::try_from(value).map_err(|_| {
1172 ObserverError::SuiteParse("expected unsigned integer literal".to_owned())
1173 }),
1174 Some(token) => Err(ObserverError::SuiteParse(format!(
1175 "expected unsigned integer literal, found {token:?}"
1176 ))),
1177 None => Err(ObserverError::SuiteParse(
1178 "unexpected end of full script".to_owned(),
1179 )),
1180 }
1181 }
1182
1183 fn expect(&mut self, expected: Token) -> ObserverResult<()> {
1184 match self.advance() {
1185 Some(token) if token == expected => Ok(()),
1186 Some(token) => Err(ObserverError::SuiteParse(format!(
1187 "expected {expected:?}, found {token:?}"
1188 ))),
1189 None => Err(ObserverError::SuiteParse(
1190 "unexpected end of full script".to_owned(),
1191 )),
1192 }
1193 }
1194
1195 fn peek_ident(&self, expected: &str) -> bool {
1196 matches!(self.tokens.get(self.position), Some(Token::Ident(value)) if value == expected)
1197 }
1198
1199 fn peek_token(&self, expected: &Token) -> bool {
1200 self.tokens.get(self.position) == Some(expected)
1201 }
1202
1203 fn advance(&mut self) -> Option<Token> {
1204 let token = self.tokens.get(self.position).cloned();
1205 if token.is_some() {
1206 self.position += 1;
1207 }
1208 token
1209 }
1210
1211 fn is_at_end(&self) -> bool {
1212 self.position >= self.tokens.len()
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219
1220 #[test]
1221 fn simple_surface_rejects_module_with_surface_hint() {
1222 let error = SuiteCore::parse_simple("module Demo.\n\ntest \"Smoke::Pass\": expect exit = 0.\n")
1223 .expect_err("module should be rejected in simple surface");
1224 assert_eq!(
1225 error.to_string(),
1226 "suite parse error: `module` is only valid in full surface; parse this suite with `--surface full`"
1227 );
1228 }
1229
1230 #[test]
1231 fn simple_surface_rejects_foreach_shape_with_surface_hint() {
1232 let error = SuiteCore::parse_simple("(prefix: \"Smoke::\") forEach: [ :name | expect: Fail msg: \"x\". ].")
1233 .expect_err("forEach should be rejected in simple surface");
1234 assert_eq!(
1235 error.to_string(),
1236 "suite parse error: inventory or workflow iteration forms like `(prefix: ...) forEach: ...` are only valid in full surface; parse this suite with `--surface full`"
1237 );
1238 }
1239
1240 #[test]
1241 fn full_surface_rejects_simple_items_with_surface_hint() {
1242 let error = SuiteCore::parse_full("test prefix: \"Smoke::\": expect exit = 0.")
1243 .expect_err("simple test item should be rejected in full surface");
1244 assert_eq!(
1245 error.to_string(),
1246 "suite parse error: simple surface `test ...` items are not valid in full surface; parse this suite with `--surface simple` or rewrite it as `(selector) forEach: ...`"
1247 );
1248 }
1249}
1250
1251fn tokenize(source: &str) -> Vec<Token> {
1252 let mut chars = source.char_indices().peekable();
1253 let mut tokens = Vec::new();
1254
1255 while let Some((index, ch)) = chars.peek().copied() {
1256 match ch {
1257 ';' => {
1258 chars.next();
1259 if matches!(chars.peek(), Some((_, ';'))) {
1260 for (_, next) in chars.by_ref() {
1261 if next == '\n' {
1262 break;
1263 }
1264 }
1265 }
1266 }
1267 ' ' | '\t' | '\n' | '\r' => {
1268 chars.next();
1269 }
1270 '(' => {
1271 chars.next();
1272 tokens.push(Token::LParen);
1273 }
1274 ')' => {
1275 chars.next();
1276 tokens.push(Token::RParen);
1277 }
1278 ':' => {
1279 chars.next();
1280 tokens.push(Token::Colon);
1281 }
1282 '.' => {
1283 chars.next();
1284 tokens.push(Token::Dot);
1285 }
1286 '[' => {
1287 chars.next();
1288 tokens.push(Token::LBracket);
1289 }
1290 ']' => {
1291 chars.next();
1292 tokens.push(Token::RBracket);
1293 }
1294 '|' => {
1295 chars.next();
1296 tokens.push(Token::Pipe);
1297 }
1298 ',' => {
1299 chars.next();
1300 tokens.push(Token::Comma);
1301 }
1302 '#' => {
1303 chars.next();
1304 tokens.push(Token::Hash);
1305 }
1306 '=' => {
1307 chars.next();
1308 tokens.push(Token::Eq);
1309 }
1310 '!' => {
1311 chars.next();
1312 if matches!(chars.peek(), Some((_, '='))) {
1313 chars.next();
1314 tokens.push(Token::Ne);
1315 }
1316 }
1317 '<' => {
1318 chars.next();
1319 if matches!(chars.peek(), Some((_, '='))) {
1320 chars.next();
1321 tokens.push(Token::Le);
1322 } else {
1323 tokens.push(Token::Lt);
1324 }
1325 }
1326 '>' => {
1327 chars.next();
1328 if matches!(chars.peek(), Some((_, '='))) {
1329 chars.next();
1330 tokens.push(Token::Ge);
1331 } else {
1332 tokens.push(Token::Gt);
1333 }
1334 }
1335 '"' => {
1336 let end = find_string_end(&source[index..]);
1337 let json = &source[index..index + end];
1338 let value = serde_json::from_str::<String>(json)
1339 .expect("simple suite string literal must tokenize correctly");
1340 advance_chars(&mut chars, end);
1341 tokens.push(Token::String(value));
1342 }
1343 '/' => {
1344 let end = find_regex_end(&source[index..]);
1345 let regex = &source[index + 1..index + end - 1];
1346 advance_chars(&mut chars, end);
1347 tokens.push(Token::Regex(regex.replace("\\/", "/")));
1348 }
1349 '-' | '0'..='9' => {
1350 let end = find_number_end(&source[index..]);
1351 let value = source[index..index + end]
1352 .parse::<i64>()
1353 .expect("simple suite integer literal must tokenize correctly");
1354 advance_chars(&mut chars, end);
1355 tokens.push(Token::Int(value));
1356 }
1357 _ if is_ident_start(ch) => {
1358 let end = find_ident_end(&source[index..]);
1359 let ident = source[index..index + end].to_owned();
1360 advance_chars(&mut chars, end);
1361 tokens.push(Token::Ident(ident));
1362 }
1363 _ => {
1364 chars.next();
1365 }
1366 }
1367 }
1368
1369 tokens
1370}
1371
1372fn advance_chars<I>(chars: &mut std::iter::Peekable<I>, bytes: usize)
1373where
1374 I: Iterator<Item = (usize, char)>,
1375{
1376 let mut consumed = 0usize;
1377 while consumed < bytes {
1378 if let Some((_, ch)) = chars.next() {
1379 consumed += ch.len_utf8();
1380 } else {
1381 break;
1382 }
1383 }
1384}
1385
1386fn find_string_end(input: &str) -> usize {
1387 let mut escaped = false;
1388 for (index, ch) in input.char_indices().skip(1) {
1389 if escaped {
1390 escaped = false;
1391 continue;
1392 }
1393 match ch {
1394 '\\' => escaped = true,
1395 '"' => return index + ch.len_utf8(),
1396 _ => {}
1397 }
1398 }
1399 input.len()
1400}
1401
1402fn find_regex_end(input: &str) -> usize {
1403 let mut escaped = false;
1404 for (index, ch) in input.char_indices().skip(1) {
1405 if escaped {
1406 escaped = false;
1407 continue;
1408 }
1409 match ch {
1410 '\\' => escaped = true,
1411 '/' => return index + ch.len_utf8(),
1412 _ => {}
1413 }
1414 }
1415 input.len()
1416}
1417
1418fn find_number_end(input: &str) -> usize {
1419 input
1420 .char_indices()
1421 .skip(1)
1422 .find(|(_, ch)| !ch.is_ascii_digit())
1423 .map(|(index, _)| index)
1424 .unwrap_or(input.len())
1425}
1426
1427fn find_ident_end(input: &str) -> usize {
1428 input
1429 .char_indices()
1430 .skip(1)
1431 .find(|(_, ch)| !is_ident_continue(*ch))
1432 .map(|(index, _)| index)
1433 .unwrap_or(input.len())
1434}
1435
1436fn is_ident_start(ch: char) -> bool {
1437 ch.is_ascii_alphabetic() || ch == '_'
1438}
1439
1440fn is_ident_continue(ch: char) -> bool {
1441 ch.is_ascii_alphanumeric() || ch == '_'
1442}