• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / oxdock-rs / 20479548851

24 Dec 2025 06:01AM UTC coverage: 83.897% (+0.2%) from 83.742%
20479548851

Pull #70

github

web-flow
Merge 2e55fb723 into 13cbac824
Pull Request #70: Unify string interpolation

722 of 830 new or added lines in 16 files covered. (86.99%)

23 existing lines in 5 files now uncovered.

6351 of 7570 relevant lines covered (83.9%)

169.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.92
/crates/internal/oxdock-parser/src/parser.rs
1
use crate::ast::{Guard, PlatformGuard, Step, StepKind, WorkspaceTarget};
2
use crate::lexer::{self, RawToken, Rule};
3
use anyhow::{Result, anyhow, bail};
4
use pest::iterators::Pair;
5
use std::collections::VecDeque;
6

7
#[derive(Clone)]
8
struct ScopeFrame {
9
    line_no: usize,
10
    had_command: bool,
11
}
12

13
pub struct ScriptParser<'a> {
14
    tokens: VecDeque<RawToken<'a>>,
15
    steps: Vec<Step>,
16
    guard_stack: Vec<Vec<Vec<Guard>>>,
17
    pending_guards: Option<Vec<Vec<Guard>>>,
18
    pending_inline_guards: Option<Vec<Vec<Guard>>>,
19
    pending_can_open_block: bool,
20
    pending_scope_enters: usize,
21
    scope_stack: Vec<ScopeFrame>,
22
}
23

24
impl<'a> ScriptParser<'a> {
25
    pub fn new(input: &'a str) -> Result<Self> {
624✔
26
        let tokens = VecDeque::from(lexer::tokenize(input)?);
624✔
27
        Ok(Self {
616✔
28
            tokens,
616✔
29
            steps: Vec::new(),
616✔
30
            guard_stack: vec![Vec::new()],
616✔
31
            pending_guards: None,
616✔
32
            pending_inline_guards: None,
616✔
33
            pending_can_open_block: false,
616✔
34
            pending_scope_enters: 0,
616✔
35
            scope_stack: Vec::new(),
616✔
36
        })
616✔
37
    }
624✔
38

39
    pub fn parse(mut self) -> Result<Vec<Step>> {
616✔
40
        while let Some(token) = self.tokens.pop_front() {
1,653✔
41
            match token {
1,041✔
42
                RawToken::Guard { pair, line_end } => {
309✔
43
                    let groups = parse_guard_line(pair)?;
309✔
44
                    self.handle_guard_token(line_end, groups)?
306✔
45
                }
46
                RawToken::BlockStart { line_no } => self.start_block_from_pending(line_no)?,
16✔
47
                RawToken::BlockEnd { line_no } => self.end_block(line_no)?,
15✔
48
                RawToken::Command { pair, line_no } => {
701✔
49
                    let kind = parse_command(pair)?;
701✔
50
                    self.handle_command_token(line_no, kind)?
701✔
51
                }
52
            }
53
        }
54

55
        if self.guard_stack.len() != 1 {
612✔
56
            bail!("unclosed guard block at end of script");
×
57
        }
612✔
58
        if let Some(pending) = &self.pending_guards
612✔
59
            && !pending.is_empty()
×
60
        {
61
            bail!("guard declared on final lines without a following command");
×
62
        }
612✔
63

64
        Ok(self.steps)
612✔
65
    }
616✔
66

67
    fn handle_guard_token(&mut self, line_end: usize, groups: Vec<Vec<Guard>>) -> Result<()> {
306✔
68
        if let Some(RawToken::Command { line_no, .. }) = self.tokens.front()
306✔
69
            && *line_no == line_end
287✔
70
        {
71
            self.pending_inline_guards = Some(groups);
146✔
72
            self.pending_can_open_block = false;
146✔
73
            return Ok(());
146✔
74
        }
160✔
75
        self.stash_pending_guard(groups);
160✔
76
        self.pending_can_open_block = true;
160✔
77
        Ok(())
160✔
78
    }
306✔
79

80
    fn handle_command_token(&mut self, line_no: usize, kind: StepKind) -> Result<()> {
701✔
81
        let inline = self.pending_inline_guards.take();
701✔
82
        self.handle_command(line_no, kind, inline)
701✔
83
    }
701✔
84

85
    fn stash_pending_guard(&mut self, groups: Vec<Vec<Guard>>) {
160✔
86
        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
160✔
87
            combine_guard_groups(&existing, &groups)
3✔
88
        } else {
89
            groups
157✔
90
        });
91
    }
160✔
92

93
    fn start_block_from_pending(&mut self, line_no: usize) -> Result<()> {
16✔
94
        let guards = self
16✔
95
            .pending_guards
16✔
96
            .take()
16✔
97
            .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
16✔
98
        if !self.pending_can_open_block {
15✔
99
            bail!("line {}: '{{' must directly follow a guard", line_no);
×
100
        }
15✔
101
        self.pending_can_open_block = false;
15✔
102
        self.start_block(guards, line_no)
15✔
103
    }
16✔
104

105
    fn start_block(&mut self, guards: Vec<Vec<Guard>>, line_no: usize) -> Result<()> {
15✔
106
        let with_pending = if let Some(pending) = self.pending_guards.take() {
15✔
107
            combine_guard_groups(&pending, &guards)
×
108
        } else {
109
            guards
15✔
110
        };
111
        let parent = self.guard_stack.last().cloned().unwrap_or_default();
15✔
112
        let next = if parent.is_empty() {
15✔
113
            with_pending
12✔
114
        } else if with_pending.is_empty() {
3✔
115
            parent
×
116
        } else {
117
            combine_guard_groups(&parent, &with_pending)
3✔
118
        };
119
        self.guard_stack.push(next);
15✔
120
        self.scope_stack.push(ScopeFrame {
15✔
121
            line_no,
15✔
122
            had_command: false,
15✔
123
        });
15✔
124
        self.pending_scope_enters += 1;
15✔
125
        Ok(())
15✔
126
    }
15✔
127

128
    fn end_block(&mut self, line_no: usize) -> Result<()> {
15✔
129
        if self.guard_stack.len() == 1 {
15✔
130
            bail!("line {}: unexpected '}}'", line_no);
×
131
        }
15✔
132
        if self.pending_guards.is_some() {
15✔
133
            bail!(
×
134
                "line {}: guard declared immediately before '}}' without a command",
×
135
                line_no
136
            );
137
        }
15✔
138
        let frame = self
15✔
139
            .scope_stack
15✔
140
            .last()
15✔
141
            .cloned()
15✔
142
            .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
15✔
143
        if !frame.had_command {
15✔
UNCOV
144
            bail!(
×
UNCOV
145
                "line {}: guard block starting on line {} must contain at least one command",
×
146
                line_no,
147
                frame.line_no
148
            );
149
        }
15✔
150
        let step = self
15✔
151
            .steps
15✔
152
            .last_mut()
15✔
153
            .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
15✔
154
        step.scope_exit += 1;
15✔
155
        self.scope_stack.pop();
15✔
156
        self.guard_stack.pop();
15✔
157
        Ok(())
15✔
158
    }
15✔
159

160
    fn guard_context(&mut self, inline: Option<Vec<Vec<Guard>>>) -> Vec<Vec<Guard>> {
701✔
161
        let mut context = if let Some(top) = self.guard_stack.last() {
701✔
162
            top.clone()
701✔
163
        } else {
164
            Vec::new()
×
165
        };
166
        if let Some(pending) = self.pending_guards.take() {
701✔
167
            context = if context.is_empty() {
142✔
168
                pending
140✔
169
            } else {
170
                combine_guard_groups(&context, &pending)
2✔
171
            };
172
            self.pending_can_open_block = false;
142✔
173
        }
559✔
174
        if let Some(inline_groups) = inline {
701✔
175
            context = if context.is_empty() {
146✔
176
                inline_groups
144✔
177
            } else {
178
                combine_guard_groups(&context, &inline_groups)
2✔
179
            };
180
        }
555✔
181
        context
701✔
182
    }
701✔
183

184
    fn handle_command(
701✔
185
        &mut self,
701✔
186
        _line_no: usize,
701✔
187
        kind: StepKind,
701✔
188
        inline_guards: Option<Vec<Vec<Guard>>>,
701✔
189
    ) -> Result<()> {
701✔
190
        let guards = self.guard_context(inline_guards);
701✔
191
        let scope_enter = self.pending_scope_enters;
701✔
192
        self.pending_scope_enters = 0;
701✔
193
        for frame in self.scope_stack.iter_mut() {
701✔
194
            frame.had_command = true;
35✔
195
        }
35✔
196
        self.steps.push(Step {
701✔
197
            guards,
701✔
198
            kind,
701✔
199
            scope_enter,
701✔
200
            scope_exit: 0,
701✔
201
        });
701✔
202
        Ok(())
701✔
203
    }
701✔
204
}
205

206
pub fn parse_script(input: &str) -> Result<Vec<Step>> {
624✔
207
    ScriptParser::new(input)?.parse()
624✔
208
}
624✔
209

210
fn combine_guard_groups(a: &[Vec<Guard>], b: &[Vec<Guard>]) -> Vec<Vec<Guard>> {
10✔
211
    if a.is_empty() {
10✔
212
        return b.to_vec();
×
213
    }
10✔
214
    if b.is_empty() {
10✔
215
        return a.to_vec();
×
216
    }
10✔
217
    let mut combined = Vec::new();
10✔
218
    for left in a {
20✔
219
        for right in b {
21✔
220
            let mut merged = left.clone();
11✔
221
            merged.extend(right.clone());
11✔
222
            combined.push(merged);
11✔
223
        }
11✔
224
    }
225
    combined
10✔
226
}
10✔
227

228
fn parse_command(pair: Pair<Rule>) -> Result<StepKind> {
730✔
229
    let kind = match pair.as_rule() {
730✔
230
        Rule::workdir_command => {
231
            let arg = parse_single_arg(pair)?;
38✔
232
            StepKind::Workdir(arg.into())
38✔
233
        }
234
        Rule::workspace_command => {
235
            let target = parse_workspace_target(pair)?;
43✔
236
            StepKind::Workspace(target)
43✔
237
        }
238
        Rule::env_command => {
239
            let (key, value) = parse_env_pair(pair)?;
50✔
240
            StepKind::Env {
50✔
241
                key,
50✔
242
                value: value.into(),
50✔
243
            }
50✔
244
        }
245
        Rule::echo_command => {
246
            let msg = parse_message(pair)?;
39✔
247
            StepKind::Echo(msg.into())
39✔
248
        }
249
        Rule::run_command => {
250
            let cmd = parse_run_args(pair)?;
87✔
251
            StepKind::Run(cmd.into())
87✔
252
        }
253
        Rule::run_bg_command => {
254
            let cmd = parse_run_args(pair)?;
36✔
255
            StepKind::RunBg(cmd.into())
36✔
256
        }
257
        Rule::copy_command => {
258
            let mut args = parse_args(pair)?;
32✔
259
            StepKind::Copy {
32✔
260
                from: args.remove(0).into(),
32✔
261
                to: args.remove(0).into(),
32✔
262
            }
32✔
263
        }
264
        Rule::capture_to_file_command => {
265
            let mut path = None;
29✔
266
            let mut cmd = None;
29✔
267
            for inner in pair.into_inner() {
58✔
268
                match inner.as_rule() {
58✔
269
                    Rule::argument => path = Some(parse_argument(inner)?),
29✔
270
                    _ => cmd = Some(Box::new(parse_command(inner)?)),
29✔
271
                }
272
            }
273
            StepKind::CaptureToFile {
274
                path: path
29✔
275
                    .ok_or_else(|| anyhow!("missing path in CAPTURE_TO_FILE"))?
29✔
276
                    .into(),
29✔
277
                cmd: cmd.ok_or_else(|| anyhow!("missing command in CAPTURE_TO_FILE"))?,
29✔
278
            }
279
        }
280
        Rule::with_io_command => {
281
            let mut streams = Vec::new();
×
282
            let mut cmd = None;
×
283
            for inner in pair.into_inner() {
×
284
                match inner.as_rule() {
×
285
                    Rule::io_flags => {
286
                        for flag in inner.into_inner() {
×
287
                            if flag.as_rule() == Rule::io_flag {
×
288
                                streams.push(flag.as_str().to_string());
×
289
                            }
×
290
                        }
291
                    }
292
                    _ => {
293
                        cmd = Some(Box::new(parse_command(inner)?));
×
294
                    }
295
                }
296
            }
297
            StepKind::WithIo {
298
                streams,
×
299
                cmd: cmd.ok_or_else(|| anyhow!("missing command in WITH_IO"))?,
×
300
            }
301
        }
302
        Rule::copy_git_command => {
303
            let mut args = Vec::new();
35✔
304
            let mut include_dirty = false;
35✔
305
            for inner in pair.into_inner() {
106✔
306
                match inner.as_rule() {
106✔
307
                    Rule::include_dirty_flag => include_dirty = true,
1✔
308
                    Rule::argument => args.push(parse_argument(inner)?),
105✔
309
                    _ => {}
×
310
                }
311
            }
312
            if args.len() != 3 {
35✔
313
                bail!("COPY_GIT expects 3 arguments (rev, from, to)");
×
314
            }
35✔
315
            StepKind::CopyGit {
35✔
316
                rev: args.remove(0).into(),
35✔
317
                from: args.remove(0).into(),
35✔
318
                to: args.remove(0).into(),
35✔
319
                include_dirty,
35✔
320
            }
35✔
321
        }
322
        Rule::hash_sha256_command => {
323
            let arg = parse_single_arg(pair)?;
34✔
324
            StepKind::HashSha256 { path: arg.into() }
34✔
325
        }
326
        Rule::symlink_command => {
327
            let mut args = parse_args(pair)?;
37✔
328
            StepKind::Symlink {
37✔
329
                from: args.remove(0).into(),
37✔
330
                to: args.remove(0).into(),
37✔
331
            }
37✔
332
        }
333
        Rule::mkdir_command => {
334
            let arg = parse_single_arg(pair)?;
28✔
335
            StepKind::Mkdir(arg.into())
28✔
336
        }
337
        Rule::ls_command => {
338
            let args = parse_args(pair)?;
45✔
339
            StepKind::Ls(args.into_iter().next().map(Into::into))
45✔
340
        }
341
        Rule::cwd_command => StepKind::Cwd,
35✔
342
        Rule::cat_command => {
343
            let args = parse_args(pair)?;
37✔
344
            StepKind::Cat(args.into_iter().next().map(Into::into))
37✔
345
        }
346
        Rule::write_command => {
347
            let path = parse_single_arg_from_pair(pair.clone())?;
94✔
348
            let contents = parse_message(pair)?;
94✔
349
            StepKind::Write {
94✔
350
                path: path.into(),
94✔
351
                contents: contents.into(),
94✔
352
            }
94✔
353
        }
354
        Rule::exit_command => {
355
            let code = parse_exit_code(pair)?;
31✔
356
            StepKind::Exit(code)
31✔
357
        }
358
        _ => bail!("unknown command rule: {:?}", pair.as_rule()),
×
359
    };
360
    Ok(kind)
730✔
361
}
730✔
362

363
fn parse_single_arg(pair: Pair<Rule>) -> Result<String> {
100✔
364
    for inner in pair.into_inner() {
100✔
365
        if inner.as_rule() == Rule::argument {
100✔
366
            return parse_argument(inner);
100✔
367
        }
×
368
    }
369
    bail!("missing argument")
×
370
}
100✔
371

372
fn parse_single_arg_from_pair(pair: Pair<Rule>) -> Result<String> {
94✔
373
    for inner in pair.into_inner() {
94✔
374
        if inner.as_rule() == Rule::argument {
94✔
375
            return parse_argument(inner);
94✔
376
        }
×
377
    }
378
    bail!("missing argument")
×
379
}
94✔
380

381
fn parse_args(pair: Pair<Rule>) -> Result<Vec<String>> {
151✔
382
    let mut args = Vec::new();
151✔
383
    for inner in pair.into_inner() {
187✔
384
        if inner.as_rule() == Rule::argument {
187✔
385
            args.push(parse_argument(inner)?);
187✔
386
        }
×
387
    }
388
    Ok(args)
151✔
389
}
151✔
390

391
fn parse_argument(pair: Pair<Rule>) -> Result<String> {
515✔
392
    let inner = pair.into_inner().next().unwrap();
515✔
393
    match inner.as_rule() {
515✔
394
        Rule::quoted_string => parse_quoted_string(inner),
302✔
395
        Rule::unquoted_arg => Ok(inner.as_str().to_string()),
213✔
396
        _ => unreachable!(),
×
397
    }
398
}
515✔
399

400
fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
447✔
401
    let s = pair.as_str();
447✔
402
    let _quote = s.chars().next().unwrap();
447✔
403
    let content = &s[1..s.len() - 1];
447✔
404

405
    let mut out = String::with_capacity(content.len());
447✔
406
    let mut escape = false;
447✔
407
    for ch in content.chars() {
7,655✔
408
        if escape {
7,655✔
409
            out.push(ch);
×
410
            escape = false;
×
411
        } else if ch == '\\' {
7,655✔
412
            escape = true;
×
413
        } else {
7,655✔
414
            out.push(ch);
7,655✔
415
        }
7,655✔
416
    }
417
    Ok(out)
447✔
418
}
447✔
419

420
fn parse_workspace_target(pair: Pair<Rule>) -> Result<WorkspaceTarget> {
43✔
421
    for inner in pair.into_inner() {
43✔
422
        if inner.as_rule() == Rule::workspace_target {
43✔
423
            return match inner.as_str().to_ascii_lowercase().as_str() {
43✔
424
                "snapshot" => Ok(WorkspaceTarget::Snapshot),
43✔
425
                "local" => Ok(WorkspaceTarget::Local),
25✔
426
                _ => bail!("unknown workspace target"),
×
427
            };
428
        }
×
429
    }
430
    bail!("missing workspace target")
×
431
}
43✔
432

433
fn parse_env_pair(pair: Pair<Rule>) -> Result<(String, String)> {
50✔
434
    for inner in pair.into_inner() {
50✔
435
        if inner.as_rule() == Rule::env_pair {
50✔
436
            let mut parts = inner.into_inner();
50✔
437
            let key = parts.next().unwrap().as_str().to_string();
50✔
438
            let value_pair = parts.next().unwrap();
50✔
439
            let value = match value_pair.as_rule() {
50✔
440
                Rule::env_value_part => {
441
                    let inner_val = value_pair.into_inner().next().unwrap();
50✔
442
                    match inner_val.as_rule() {
50✔
443
                        Rule::quoted_string => parse_quoted_string(inner_val)?,
34✔
444
                        Rule::unquoted_env_value => inner_val.as_str().to_string(),
16✔
445
                        _ => unreachable!(
×
446
                            "unexpected rule in env_value_part: {:?}",
447
                            inner_val.as_rule()
×
448
                        ),
449
                    }
450
                }
451
                _ => unreachable!("expected env_value_part"),
×
452
            };
453
            return Ok((key, value));
50✔
454
        }
×
455
    }
456
    bail!("missing env pair")
×
457
}
50✔
458

459
fn parse_message(pair: Pair<Rule>) -> Result<String> {
133✔
460
    for inner in pair.into_inner() {
227✔
461
        if inner.as_rule() == Rule::message {
227✔
462
            return parse_concatenated_string(inner);
133✔
463
        }
94✔
464
    }
465
    bail!("missing message")
×
466
}
133✔
467

468
fn parse_run_args(pair: Pair<Rule>) -> Result<String> {
123✔
469
    for inner in pair.into_inner() {
123✔
470
        if inner.as_rule() == Rule::run_args {
123✔
471
            return parse_smart_concatenated_string(inner);
123✔
472
        }
×
473
    }
474
    bail!("missing run args")
×
475
}
123✔
476

477
fn parse_smart_concatenated_string(pair: Pair<Rule>) -> Result<String> {
123✔
478
    let parts: Vec<_> = pair.into_inner().collect();
123✔
479

480
    // Special case: If there is only one token and it is quoted, we assume the user
481
    // quoted it to satisfy the DSL (e.g. to include semicolons) but intends for the
482
    // content to be the raw command string. We unquote it unconditionally.
483
    if parts.len() == 1 && parts[0].as_rule() == Rule::quoted_string {
123✔
484
        return parse_quoted_string(parts[0].clone());
18✔
485
    }
105✔
486

487
    let mut body = String::new();
105✔
488
    let mut last_end = None;
105✔
489
    for part in parts {
1,284✔
490
        let span = part.as_span();
1,179✔
491
        if let Some(end) = last_end
1,179✔
492
            && span.start() > end
1,074✔
493
        {
×
494
            body.push(' ');
×
495
        }
1,179✔
496
        match part.as_rule() {
1,179✔
497
            Rule::quoted_string => {
498
                let raw = part.as_str();
37✔
499
                let unquoted = parse_quoted_string(part.clone())?;
37✔
500
                // Preserve quotes if the content needs them to be parsed correctly
501
                // by the shell (e.g. contains spaces, semicolons, etc).
502
                let needs_quotes = unquoted.is_empty()
37✔
503
                    || unquoted
37✔
504
                        .chars()
37✔
505
                        .any(|c| c.is_whitespace() || c == ';' || c == '\n' || c == '\r')
222✔
506
                    || unquoted.contains("//")
27✔
507
                    || unquoted.contains("/*");
27✔
508

509
                if needs_quotes {
37✔
510
                    body.push_str(raw);
10✔
511
                } else {
27✔
512
                    body.push_str(&unquoted);
27✔
513
                }
27✔
514
            }
515
            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
1,142✔
516
            _ => {}
×
517
        }
518
        last_end = Some(span.end());
1,179✔
519
    }
520
    Ok(body)
105✔
521
}
123✔
522

523
fn parse_concatenated_string(pair: Pair<Rule>) -> Result<String> {
133✔
524
    let mut body = String::new();
133✔
525
    let mut last_end = None;
133✔
526
    for part in pair.into_inner() {
367✔
527
        let span = part.as_span();
367✔
528
        if let Some(end) = last_end
367✔
529
            && span.start() > end
234✔
530
        {
×
531
            body.push(' ');
×
532
        }
367✔
533
        match part.as_rule() {
367✔
534
            Rule::quoted_string => body.push_str(&parse_quoted_string(part)?),
56✔
535
            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
311✔
536
            _ => {}
×
537
        }
538
        last_end = Some(span.end());
367✔
539
    }
540
    Ok(body)
133✔
541
}
133✔
542

543
fn parse_exit_code(pair: Pair<Rule>) -> Result<i32> {
31✔
544
    for inner in pair.into_inner() {
31✔
545
        if inner.as_rule() == Rule::exit_code {
31✔
546
            return inner
31✔
547
                .as_str()
31✔
548
                .parse()
31✔
549
                .map_err(|_| anyhow!("invalid exit code"));
31✔
550
        }
×
551
    }
552
    bail!("missing exit code")
×
553
}
31✔
554

555
fn parse_guard_line(pair: Pair<Rule>) -> Result<Vec<Vec<Guard>>> {
309✔
556
    let mut groups = Vec::new();
309✔
557
    for inner in pair.into_inner() {
309✔
558
        if inner.as_rule() == Rule::guard_groups {
309✔
559
            groups = parse_guard_groups(inner)?;
309✔
560
        }
×
561
    }
562
    Ok(groups)
306✔
563
}
309✔
564

565
fn parse_guard_groups(pair: Pair<Rule>) -> Result<Vec<Vec<Guard>>> {
309✔
566
    let mut groups = Vec::new();
309✔
567
    for inner in pair.into_inner() {
310✔
568
        if inner.as_rule() == Rule::guard_conjunction {
310✔
569
            groups.push(parse_guard_conjunction(inner)?);
310✔
570
        }
×
571
    }
572
    Ok(groups)
306✔
573
}
309✔
574

575
fn parse_guard_conjunction(pair: Pair<Rule>) -> Result<Vec<Guard>> {
310✔
576
    let mut group = Vec::new();
310✔
577
    for inner in pair.into_inner() {
440✔
578
        if inner.as_rule() == Rule::guard_term {
440✔
579
            group.push(parse_guard_term(inner)?);
440✔
580
        }
×
581
    }
582
    Ok(group)
307✔
583
}
310✔
584

585
fn parse_guard_term(pair: Pair<Rule>) -> Result<Guard> {
440✔
586
    let mut invert = false;
440✔
587
    let mut guard = None;
440✔
588

589
    for inner in pair.into_inner() {
586✔
590
        match inner.as_rule() {
586✔
591
            Rule::invert => invert = true,
146✔
592
            Rule::env_guard => guard = Some(parse_env_guard(inner, invert)?),
309✔
593
            Rule::platform_guard => guard = Some(parse_platform_guard(inner, invert)?),
127✔
594
            Rule::bare_platform => guard = Some(parse_bare_platform(inner, invert)?),
4✔
595
            _ => {}
×
596
        }
597
    }
598
    guard.ok_or_else(|| anyhow!("missing guard predicate"))
437✔
599
}
440✔
600

601
fn parse_env_guard(pair: Pair<Rule>, invert: bool) -> Result<Guard> {
309✔
602
    let mut key = String::new();
309✔
603
    let mut value = None;
309✔
604
    let mut is_not_equals = false;
309✔
605

606
    for inner in pair.into_inner() {
756✔
607
        match inner.as_rule() {
756✔
608
            Rule::env_key => key = inner.as_str().trim().to_string(),
309✔
609
            Rule::env_comparison => {
610
                for comp_part in inner.into_inner() {
138✔
611
                    match comp_part.as_rule() {
138✔
612
                        Rule::equals_env | Rule::not_equals_env => {
613
                            for part in comp_part.into_inner() {
276✔
614
                                match part.as_rule() {
276✔
615
                                    Rule::eq_op => {}
63✔
616
                                    Rule::neq_op => is_not_equals = true,
75✔
617
                                    Rule::env_value => {
138✔
618
                                        value = Some(part.as_str().trim().to_string());
138✔
619
                                    }
138✔
620
                                    _ => {}
×
621
                                }
622
                            }
623
                        }
624
                        Rule::eq_op => {}
×
625
                        Rule::neq_op => is_not_equals = true,
×
626
                        Rule::env_value => {
×
627
                            value = Some(comp_part.as_str().trim().to_string());
×
628
                        }
×
629
                        _ => {}
×
630
                    }
631
                }
632
            }
633
            _ => {}
309✔
634
        }
635
    }
636

637
    if let Some(val) = value {
309✔
638
        // Disallow the confusing pattern `!env:KEY==value` — require using
639
        // `env:KEY!=value` or `!env:KEY` for negation without equality.
640
        if invert && !is_not_equals {
138✔
641
            bail!(
3✔
642
                "inverted env equality is not allowed: use 'env:{}!={}' or '!env:{}'",
3✔
643
                key,
644
                val,
645
                key
646
            );
647
        }
135✔
648
        Ok(Guard::EnvEquals {
135✔
649
            key,
135✔
650
            value: val,
135✔
651
            invert: invert ^ is_not_equals,
135✔
652
        })
135✔
653
    } else {
654
        Ok(Guard::EnvExists { key, invert })
171✔
655
    }
656
}
309✔
657

658
fn parse_platform_guard(pair: Pair<Rule>, invert: bool) -> Result<Guard> {
127✔
659
    // New platform syntax: `platform:tag`. We ignore legacy ==/!= comparisons.
660
    let mut tag = "";
127✔
661
    for inner in pair.into_inner() {
127✔
662
        if inner.as_rule() == Rule::platform_tag {
127✔
663
            tag = inner.as_str();
127✔
664
            break;
127✔
UNCOV
665
        }
×
666
    }
667
    parse_platform_tag(tag, invert)
127✔
668
}
127✔
669

670
fn parse_bare_platform(pair: Pair<Rule>, invert: bool) -> Result<Guard> {
4✔
671
    let tag = pair.into_inner().next().unwrap().as_str();
4✔
672
    parse_platform_tag(tag, invert)
4✔
673
}
4✔
674

675
fn parse_platform_tag(tag: &str, invert: bool) -> Result<Guard> {
131✔
676
    let target = match tag.to_ascii_lowercase().as_str() {
131✔
677
        "unix" => PlatformGuard::Unix,
131✔
678
        "windows" => PlatformGuard::Windows,
103✔
679
        "mac" | "macos" => PlatformGuard::Macos,
59✔
680
        "linux" => PlatformGuard::Linux,
19✔
681
        _ => bail!("unknown platform '{}'", tag),
×
682
    };
683
    Ok(Guard::Platform { target, invert })
131✔
684
}
131✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc