Skip to main content

observer_core/
suite.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use 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}