use std::fmt;
use std::time::Duration;
use super::ast::{
CondOp, Condition, Deployment, Environment, FluxConfig, Policy, RunnerPool, Step, Timeout,
CONDITION_VARS,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub line: usize,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseWarning {
pub line: usize,
pub message: String,
}
impl fmt::Display for ParseWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
impl ParseError {
fn new(line: usize, message: impl Into<String>) -> Self {
ParseError {
line,
message: message.into(),
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TokKind {
Ident,
Str,
Num,
Op, LBrace,
RBrace,
LBracket,
RBracket,
Comma,
}
#[derive(Debug, Clone)]
struct Token {
kind: TokKind,
text: String,
line: usize,
}
fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
}
fn lex(src: &str) -> Result<Vec<Token>, ParseError> {
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
let mut tokens = Vec::new();
let mut line = 1usize;
let mut chars = src.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
'\n' => {
line += 1;
chars.next();
}
':' => {
chars.next();
}
c if c.is_whitespace() => {
chars.next();
}
'#' => {
while let Some(&c) = chars.peek() {
if c == '\n' {
break;
}
chars.next();
}
}
'/' => {
chars.next();
if chars.peek() == Some(&'/') {
while let Some(&c) = chars.peek() {
if c == '\n' {
break;
}
chars.next();
}
} else {
return Err(ParseError::new(
line,
"unexpected '/' (did you mean '//' ?)",
));
}
}
'{' => {
push(&mut tokens, TokKind::LBrace, "{", line);
chars.next();
}
'}' => {
push(&mut tokens, TokKind::RBrace, "}", line);
chars.next();
}
'[' => {
push(&mut tokens, TokKind::LBracket, "[", line);
chars.next();
}
']' => {
push(&mut tokens, TokKind::RBracket, "]", line);
chars.next();
}
',' => {
push(&mut tokens, TokKind::Comma, ",", line);
chars.next();
}
'=' => {
chars.next();
if chars.peek() == Some(&'=') {
chars.next();
push(&mut tokens, TokKind::Op, "==", line);
} else {
return Err(ParseError::new(
line,
"unexpected '=' (did you mean '==' ?)",
));
}
}
'!' => {
chars.next();
if chars.peek() == Some(&'=') {
chars.next();
push(&mut tokens, TokKind::Op, "!=", line);
} else {
return Err(ParseError::new(
line,
"unexpected '!' (did you mean '!=' ?)",
));
}
}
'"' => {
chars.next(); let start_line = line;
let mut s = String::new();
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some('n') => s.push('\n'),
Some('t') => s.push('\t'),
Some('"') => s.push('"'),
Some('\\') => s.push('\\'),
Some(other) => s.push(other),
None => return Err(ParseError::new(start_line, "unterminated string")),
},
Some('\n') => {
return Err(ParseError::new(
start_line,
"newline inside string literal",
))
}
Some(other) => s.push(other),
None => return Err(ParseError::new(start_line, "unterminated string")),
}
}
push(&mut tokens, TokKind::Str, &s, start_line);
}
c if c.is_ascii_digit() => {
let mut s = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
s.push(c);
chars.next();
} else {
break;
}
}
push(&mut tokens, TokKind::Num, &s, line);
}
c if is_ident_start(c) => {
let mut s = String::new();
while let Some(&c) = chars.peek() {
if is_ident_char(c) {
s.push(c);
chars.next();
} else {
break;
}
}
push(&mut tokens, TokKind::Ident, &s, line);
}
other => {
return Err(ParseError::new(
line,
format!("unexpected character '{other}'"),
))
}
}
}
Ok(tokens)
}
fn push(tokens: &mut Vec<Token>, kind: TokKind, text: &str, line: usize) {
tokens.push(Token {
kind,
text: text.to_string(),
line,
});
}
struct Parser {
toks: Vec<Token>,
pos: usize,
warnings: Vec<ParseWarning>,
}
impl Parser {
fn warn(&mut self, line: usize, message: impl Into<String>) {
self.warnings.push(ParseWarning {
line,
message: message.into(),
});
}
fn peek(&self) -> Option<&Token> {
self.toks.get(self.pos)
}
fn next(&mut self) -> Option<Token> {
let t = self.toks.get(self.pos).cloned();
if t.is_some() {
self.pos += 1;
}
t
}
fn last_line(&self) -> usize {
self.toks
.get(self.pos.saturating_sub(1))
.map(|t| t.line)
.unwrap_or(0)
}
fn expect(&mut self, kind: TokKind, what: &str) -> Result<Token, ParseError> {
match self.next() {
Some(t) if t.kind == kind => Ok(t),
Some(t) => Err(ParseError::new(
t.line,
format!("expected {what}, found '{}'", t.text),
)),
None => Err(ParseError::new(
self.last_line(),
format!("expected {what}, found end of file"),
)),
}
}
fn expect_lbrace(&mut self) -> Result<(), ParseError> {
self.expect(TokKind::LBrace, "'{'").map(|_| ())
}
fn expect_str(&mut self) -> Result<String, ParseError> {
self.expect(TokKind::Str, "a quoted string").map(|t| t.text)
}
fn expect_ident(&mut self) -> Result<(String, usize), ParseError> {
self.expect(TokKind::Ident, "an identifier")
.map(|t| (t.text, t.line))
}
fn expect_number(&mut self) -> Result<(u32, usize), ParseError> {
let t = self.expect(TokKind::Num, "a number")?;
let n = t
.text
.parse::<u32>()
.map_err(|_| ParseError::new(t.line, format!("'{}' is not a valid number", t.text)))?;
Ok((n, t.line))
}
fn expect_str_or_ident(&mut self) -> Result<String, ParseError> {
match self.next() {
Some(t) if t.kind == TokKind::Str || t.kind == TokKind::Ident => Ok(t.text),
Some(t) => Err(ParseError::new(
t.line,
format!("expected a name or string, found '{}'", t.text),
)),
None => Err(ParseError::new(
self.last_line(),
"expected a name or string, found end of file",
)),
}
}
}
#[cfg(test)]
pub fn parse(src: &str) -> Result<FluxConfig, ParseError> {
parse_with_warnings(src).map(|(cfg, _)| cfg)
}
pub fn parse_with_warnings(src: &str) -> Result<(FluxConfig, Vec<ParseWarning>), ParseError> {
let toks = lex(src)?;
let mut p = Parser {
toks,
pos: 0,
warnings: Vec::new(),
};
let mut cfg = FluxConfig::default();
while let Some(tok) = p.peek().cloned() {
if tok.kind != TokKind::Ident {
return Err(ParseError::new(
tok.line,
format!("expected a top-level keyword, found '{}'", tok.text),
));
}
match tok.text.as_str() {
"project" => {
p.next();
cfg.project = Some(p.expect_str()?);
}
"language" => {
p.next();
cfg.language = Some(p.expect_ident()?.0);
}
"environment" => {
p.next();
cfg.environment = Some(parse_environment(&mut p)?);
}
"secret" => {
p.next();
cfg.secrets.push(p.expect_ident()?.0);
}
"deployment" => {
p.next();
cfg.deployment = Some(parse_deployment(&mut p)?);
}
"import" => {
return Err(ParseError::new(
tok.line,
"the top-level 'import' directive was removed: it declared a module but never loaded one. Use `use <name>` inside `pipeline { … }` to splice in modules/<name>.flux",
))
}
"runners" => {
p.next();
cfg.runner_pools = parse_runners(&mut p)?;
}
"policy" => {
p.next();
cfg.policies.push(parse_policy(&mut p)?);
}
"pipeline" => {
p.next();
parse_pipeline(&mut p, &mut cfg)?;
}
other => {
return Err(ParseError::new(
tok.line,
format!(
"unknown top-level keyword '{other}' (expected project, language, environment, secret, deployment, runners, policy, or pipeline)"
),
))
}
}
}
Ok((cfg, p.warnings))
}
fn parse_environment(p: &mut Parser) -> Result<Environment, ParseError> {
p.expect_lbrace()?;
let mut env = Environment::default();
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => {
return Err(ParseError::new(
p.last_line(),
"unclosed 'environment' block",
))
}
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
let (field, line) = p.expect_ident()?;
match field.as_str() {
"image" => env.image = Some(p.expect_str()?),
other => {
return Err(ParseError::new(
line,
format!("unknown environment field '{other}' (expected image)"),
))
}
}
}
Ok(env)
}
fn parse_deployment(p: &mut Parser) -> Result<Deployment, ParseError> {
p.expect_lbrace()?;
let mut dep = Deployment::default();
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => {
return Err(ParseError::new(
p.last_line(),
"unclosed 'deployment' block",
))
}
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
let (field, line) = p.expect_ident()?;
match field.as_str() {
"target" => dep.target = Some(p.expect_ident()?.0),
"replicas" => dep.replicas = Some(p.expect_number()?.0),
"image" => dep.image = Some(p.expect_str()?),
other => {
return Err(ParseError::new(
line,
format!(
"unknown deployment field '{other}' (expected target, replicas, or image)"
),
))
}
}
}
Ok(dep)
}
fn parse_pipeline(p: &mut Parser, cfg: &mut FluxConfig) -> Result<(), ParseError> {
p.expect_lbrace()?;
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed 'pipeline' block")),
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
if tok.kind == TokKind::Ident && tok.text == "step" {
p.next();
let step = parse_step(p)?;
cfg.steps.push(step);
} else if tok.kind == TokKind::Ident && tok.text == "use" {
p.next();
cfg.uses.push(p.expect_str_or_ident()?);
} else if tok.kind == TokKind::Ident && tok.text == "timeout" {
p.next();
cfg.execution.timeout = Some(parse_timeout(p)?);
} else if tok.kind == TokKind::Ident && tok.text == "parallel" {
p.next();
cfg.execution.parallel = Some(parse_parallel(p)?);
} else {
return Err(ParseError::new(
tok.line,
format!(
"expected 'step', 'use', 'timeout', 'parallel', or '}}', found '{}'",
tok.text
),
));
}
}
Ok(())
}
fn parse_step(p: &mut Parser) -> Result<Step, ParseError> {
let (name, _) = p.expect_ident()?;
let mut step = Step::new(name);
p.expect_lbrace()?;
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed 'step' block")),
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
if tok.kind != TokKind::Ident {
return Err(ParseError::new(
tok.line,
format!("expected a step field, found '{}'", tok.text),
));
}
p.next();
match tok.text.as_str() {
"command" => step.command = Some(p.expect_str()?),
"tool" => step.tool = Some(p.expect_ident()?.0),
"description" => step.description = Some(p.expect_str()?),
"cache" => {
let (v, line) = p.expect_ident()?;
step.cache = match v.as_str() {
"on" | "true" | "yes" => true,
"off" | "false" | "no" => false,
other => {
return Err(ParseError::new(
line,
format!("invalid cache value '{other}' (expected on/off)"),
))
}
};
}
"needs" => step.needs = parse_ident_or_list(p)?,
"secrets" => step.secrets = parse_ident_or_list(p)?,
"env" => {
step.secrets = parse_ident_or_list(p)?;
p.warn(
tok.line,
format!(
"step '{}': the 'env' field is deprecated, rename it to 'secrets'. It will be removed in the next release, freeing 'env' for real environment variables. `flux format` rewrites it for you",
step.name
),
);
}
"inputs" => step.inputs = parse_ident_or_list(p)?,
"pool" => {
return Err(ParseError::new(
tok.line,
format!(
"the step field 'pool' was removed from step '{}': it never reached the scheduler. Declare pools with the top-level `runners {{ pool … }}` block and see them with `flux runners list`",
step.name
),
))
}
"retries" => step.retries = p.expect_number()?.0,
"timeout" => step.timeout = Some(parse_timeout(p)?),
"only_if" => step.only_if = Some(parse_condition(p)?),
other => {
return Err(ParseError::new(
tok.line,
format!(
"unknown step field '{other}' (expected command, tool, description, cache, needs, secrets, inputs, retries, timeout, or only_if)"
),
))
}
}
}
if step.command.is_none() && step.tool.is_none() {
return Err(ParseError::new(
p.last_line(),
format!("step '{}' has neither a command nor a tool", step.name),
));
}
Ok(step)
}
fn parse_ident_or_list(p: &mut Parser) -> Result<Vec<String>, ParseError> {
match p.peek().cloned() {
Some(t) if t.kind == TokKind::LBracket => {
p.next();
let mut items = Vec::new();
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed '[' list")),
};
if tok.kind == TokKind::RBracket {
p.next();
break;
}
if tok.kind == TokKind::Comma {
p.next();
continue;
}
items.push(p.expect_str_or_ident()?);
}
Ok(items)
}
Some(t) if t.kind == TokKind::Ident || t.kind == TokKind::Str => {
Ok(vec![p.expect_str_or_ident()?])
}
Some(t) => Err(ParseError::new(
t.line,
format!("expected an item or '[', found '{}'", t.text),
)),
None => Err(ParseError::new(p.last_line(), "expected an item or '['")),
}
}
const DURATION_UNITS: &[(&str, u64)] = &[("h", 3600), ("m", 60), ("s", 1)];
fn parse_timeout(p: &mut Parser) -> Result<Timeout, ParseError> {
match p.peek().cloned() {
Some(t) if t.kind == TokKind::Num => {
let (secs, line) = p.expect_number()?;
if let Some(unit) = p.peek().filter(|n| {
n.kind == TokKind::Ident && DURATION_UNITS.iter().any(|(u, _)| *u == n.text)
}) {
return Err(ParseError::new(
unit.line,
format!(
"a timeout with a unit must be quoted: write `timeout \"{secs}{}\"`",
unit.text
),
));
}
if secs == 0 {
return Err(ParseError::new(line, zero_timeout_message()));
}
Ok(Timeout::After(Duration::from_secs(secs as u64)))
}
Some(t) if t.kind == TokKind::Str => {
p.next();
let secs = parse_duration_literal(&t.text).ok_or_else(|| {
ParseError::new(
t.line,
format!(
"invalid timeout '{}' (expected a number and one of s, m, h, e.g. \"90s\", \"10m\", \"2h\")",
t.text
),
)
})?;
if secs == 0 {
return Err(ParseError::new(t.line, zero_timeout_message()));
}
Ok(Timeout::After(Duration::from_secs(secs)))
}
Some(t) if t.kind == TokKind::Ident => {
let (word, line) = p.expect_ident()?;
match word.as_str() {
"off" | "none" => Ok(Timeout::Off),
other => Err(ParseError::new(
line,
format!(
"invalid timeout '{other}' (expected a number of seconds, a quoted duration like \"10m\", or 'off')"
),
)),
}
}
Some(t) => Err(ParseError::new(
t.line,
format!("expected a timeout value, found '{}'", t.text),
)),
None => Err(ParseError::new(
p.last_line(),
"expected a timeout value, found end of file",
)),
}
}
fn zero_timeout_message() -> String {
"a timeout of 0 would kill the command immediately; write `timeout off` to run it unbounded"
.to_string()
}
fn parse_duration_literal(text: &str) -> Option<u64> {
let text = text.trim();
let (unit, secs_per) = DURATION_UNITS
.iter()
.find(|(u, _)| text.len() > u.len() && text.ends_with(u))?;
let digits = &text[..text.len() - unit.len()];
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
digits.parse::<u64>().ok()?.checked_mul(*secs_per)
}
fn parse_parallel(p: &mut Parser) -> Result<u32, ParseError> {
let (n, line) = p.expect_number()?;
if n == 0 {
return Err(ParseError::new(
line,
"parallel must be at least 1 (it caps concurrent steps; it cannot disable them)",
));
}
Ok(n)
}
fn parse_runners(p: &mut Parser) -> Result<Vec<RunnerPool>, ParseError> {
p.expect_lbrace()?;
let mut pools = Vec::new();
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed 'runners' block")),
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
let (kw, line) = p.expect_ident()?;
if kw != "pool" {
return Err(ParseError::new(
line,
format!("expected 'pool' or '}}', found '{kw}'"),
));
}
let name = p.expect_str_or_ident()?;
let mut pool = RunnerPool {
name,
..RunnerPool::default()
};
p.expect_lbrace()?;
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed 'pool' block")),
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
if tok.kind == TokKind::Comma {
p.next();
continue;
}
let (field, fline) = p.expect_ident()?;
match field.as_str() {
"requirements" => parse_requirements(p, &mut pool)?,
"os" => pool.os = Some(p.expect_str_or_ident()?),
"gpu" => pool.gpu = Some(parse_bool(p)?),
"memory" => pool.memory = Some(p.expect_str_or_ident()?),
other => {
return Err(ParseError::new(
fline,
format!(
"unknown pool field '{other}' (expected requirements, os, gpu, or memory)"
),
))
}
}
}
pools.push(pool);
}
Ok(pools)
}
fn parse_requirements(p: &mut Parser, pool: &mut RunnerPool) -> Result<(), ParseError> {
p.expect_lbrace()?;
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => {
return Err(ParseError::new(
p.last_line(),
"unclosed 'requirements' block",
))
}
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
if tok.kind == TokKind::Comma {
p.next();
continue;
}
let (field, line) = p.expect_ident()?;
match field.as_str() {
"gpu" => pool.gpu = Some(parse_bool(p)?),
"memory" => pool.memory = Some(p.expect_str_or_ident()?),
"os" => pool.os = Some(p.expect_str_or_ident()?),
other => {
return Err(ParseError::new(
line,
format!("unknown requirement '{other}' (expected gpu, memory, or os)"),
))
}
}
}
Ok(())
}
fn parse_bool(p: &mut Parser) -> Result<bool, ParseError> {
let v = p.expect_str_or_ident()?;
Ok(matches!(v.as_str(), "true" | "yes" | "on"))
}
fn parse_policy(p: &mut Parser) -> Result<Policy, ParseError> {
let name = p.expect_str_or_ident()?;
let mut policy = Policy {
name,
..Policy::default()
};
p.expect_lbrace()?;
loop {
let tok = match p.peek().cloned() {
Some(t) => t,
None => return Err(ParseError::new(p.last_line(), "unclosed 'policy' block")),
};
if tok.kind == TokKind::RBrace {
p.next();
break;
}
if tok.kind == TokKind::Comma {
p.next();
continue;
}
let (kw, line) = p.expect_ident()?;
if kw != "require" {
return Err(ParseError::new(
line,
format!("expected 'require' in policy, found '{kw}'"),
));
}
let (what, wline) = p.expect_ident()?;
match what.as_str() {
"tests" => policy.require_tests = true,
"security" => policy.require_security = true,
"approvals" => policy.require_approvals = p.expect_number()?.0,
other => {
return Err(ParseError::new(
wline,
format!(
"unknown policy requirement '{other}' (expected tests, security, or approvals)"
),
))
}
}
}
Ok(policy)
}
fn parse_condition(p: &mut Parser) -> Result<Condition, ParseError> {
let (var, line) = p.expect_ident()?;
if !CONDITION_VARS.contains(&var.as_str()) {
return Err(ParseError::new(
line,
format!(
"unknown only_if variable '{var}' (expected {})",
CONDITION_VARS.join(", ")
),
));
}
let op_tok = p.expect(TokKind::Op, "'==' or '!='")?;
let op = match op_tok.text.as_str() {
"==" => CondOp::Eq,
"!=" => CondOp::Ne,
_ => unreachable!("lexer only emits == or !="),
};
let value = p.expect_str()?;
Ok(Condition { var, op, value })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_reference_example() {
let src = r#"
project "my-app"
language rust
pipeline {
step dependencies { command "cargo fetch" }
step build { command "cargo build --release" }
step test { command "cargo test" }
}
"#;
let cfg = parse(src).expect("should parse");
assert_eq!(cfg.project.as_deref(), Some("my-app"));
assert_eq!(cfg.language.as_deref(), Some("rust"));
assert_eq!(cfg.steps.len(), 3);
assert_eq!(cfg.steps[1].name, "build");
assert_eq!(
cfg.steps[1].command.as_deref(),
Some("cargo build --release")
);
assert!(cfg.steps[1].cache);
}
#[test]
fn parses_a_file_with_a_utf8_bom() {
let body = "project \"my-app\"\nlanguage rust\npipeline {\n step build { command \"cargo build\" }\n}\n";
let with_bom = format!("\u{feff}{body}");
assert_eq!(&with_bom.as_bytes()[..3], &[0xEF, 0xBB, 0xBF]);
let cfg = parse(&with_bom).expect("a BOM-prefixed file should parse");
assert_eq!(cfg.project.as_deref(), Some("my-app"));
assert_eq!(cfg.language.as_deref(), Some("rust"));
assert_eq!(cfg.steps.len(), 1);
assert_eq!(cfg.steps[0].name, "build");
let plain = parse(body).unwrap();
assert_eq!(cfg.project, plain.project);
assert_eq!(cfg.steps.len(), plain.steps.len());
}
#[test]
fn a_bom_in_the_middle_is_still_an_error() {
let src = "project \"my-app\"\n\u{feff}language rust\n";
assert!(parse(src).is_err());
}
#[test]
fn parses_tool_hooks_and_cache_flag() {
let src = r#"
project "svc"
language node
pipeline {
step build { command "npm run build" cache off }
step security { tool scanner }
}
"#;
let cfg = parse(src).unwrap();
assert!(!cfg.steps[0].cache);
assert_eq!(cfg.steps[1].tool.as_deref(), Some("scanner"));
assert!(cfg.steps[1].is_hook());
}
#[test]
fn supports_comments() {
let src = "# a comment\nproject \"x\" // trailing\nlanguage python\n";
let cfg = parse(src).unwrap();
assert_eq!(cfg.project.as_deref(), Some("x"));
assert_eq!(cfg.language.as_deref(), Some("python"));
}
#[test]
fn reports_unknown_keyword_with_line() {
let err = parse("\nbogus \"x\"\n").unwrap_err();
assert_eq!(err.line, 2);
}
#[test]
fn rejects_empty_step() {
let err = parse("pipeline { step build { } }").unwrap_err();
assert!(err.message.contains("neither a command nor a tool"));
}
#[test]
fn parses_needs_list_and_single() {
let src = r#"
pipeline {
step frontend { command "npm build" }
step backend { command "cargo build" }
step tests {
needs [ frontend, backend ]
command "./run-tests"
}
step package {
needs tests
command "docker build ."
}
}
"#;
let cfg = parse(src).unwrap();
let tests = cfg.steps.iter().find(|s| s.name == "tests").unwrap();
assert_eq!(tests.needs, vec!["frontend", "backend"]);
let package = cfg.steps.iter().find(|s| s.name == "package").unwrap();
assert_eq!(package.needs, vec!["tests"]);
}
#[test]
fn parses_only_if_retries_and_secrets() {
let src = r#"
secret DATABASE_URL
pipeline {
step deploy {
command "./deploy"
only_if: branch == "main"
retries 3
secrets: [ DATABASE_URL ]
}
}
"#;
let (cfg, warnings) = parse_with_warnings(src).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(cfg.secrets, vec!["DATABASE_URL"]);
let deploy = &cfg.steps[0];
assert_eq!(deploy.retries, 3);
assert_eq!(deploy.secrets, vec!["DATABASE_URL"]);
let cond = deploy.only_if.as_ref().unwrap();
assert_eq!(cond.var, "branch");
assert_eq!(cond.op, CondOp::Eq);
assert_eq!(cond.value, "main");
}
#[test]
fn env_is_a_deprecated_alias_for_secrets() {
let src = r#"
pipeline {
step deploy {
command "./deploy"
env [ TOKEN ]
}
}
"#;
let (cfg, warnings) = parse_with_warnings(src).unwrap();
assert_eq!(cfg.steps[0].secrets, vec!["TOKEN"]);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line, 5, "the warning must point at the field");
assert!(warnings[0].message.contains("'env' field is deprecated"));
assert!(warnings[0].message.contains("secrets"));
}
#[test]
fn removed_keywords_report_their_replacement() {
let src = "pipeline { step build { command \"x\" pool \"gpu\" } }";
let pool_err = parse(src).unwrap_err();
assert!(
pool_err.message.contains("'pool' was removed"),
"{pool_err}"
);
assert!(pool_err.message.contains("runners"), "{pool_err}");
let imp_err = parse("import shared-ci\n").unwrap_err();
assert!(imp_err.message.contains("was removed"), "{imp_err}");
assert!(imp_err.message.contains("use <name>"), "{imp_err}");
assert_eq!(imp_err.line, 1);
}
#[test]
fn only_if_rejects_variables_outside_the_namespace() {
let src = "pipeline { step d { command \"x\" only_if brunch == \"main\" } }";
let err = parse(src).unwrap_err();
assert!(err.message.contains("unknown only_if variable"), "{err}");
for var in CONDITION_VARS {
assert!(err.message.contains(var), "{err} should list '{var}'");
}
}
#[test]
fn only_if_accepts_the_whole_documented_namespace() {
for var in CONDITION_VARS {
for op in ["==", "!="] {
let src =
format!("pipeline {{ step d {{ command \"x\" only_if {var} {op} \"v\" }} }}");
let cfg = parse(&src).unwrap_or_else(|e| panic!("{var} {op}: {e}"));
assert_eq!(cfg.steps[0].only_if.as_ref().unwrap().var, *var);
}
}
}
#[test]
fn parses_runner_pools_policies_and_step_scoping() {
let src = r#"
runners {
pool "gpu-builders" {
requirements { gpu true, memory "32gb" }
}
pool linux { os linux }
}
policy production {
require tests
require security
require approvals 2
}
pipeline {
use rust-library
step build {
command "cargo build --release"
inputs [ "src/**", "Cargo.toml" ]
}
}
"#;
let cfg = parse(src).unwrap();
assert_eq!(cfg.uses, vec!["rust-library"]);
assert_eq!(cfg.runner_pools.len(), 2);
let gpu = &cfg.runner_pools[0];
assert_eq!(gpu.name, "gpu-builders");
assert_eq!(gpu.gpu, Some(true));
assert_eq!(gpu.memory.as_deref(), Some("32gb"));
assert_eq!(cfg.runner_pools[1].os.as_deref(), Some("linux"));
assert_eq!(cfg.policies.len(), 1);
let policy = &cfg.policies[0];
assert_eq!(policy.name, "production");
assert!(policy.require_tests);
assert!(policy.require_security);
assert_eq!(policy.require_approvals, 2);
let build = &cfg.steps[0];
assert_eq!(build.inputs, vec!["src/**", "Cargo.toml"]);
}
#[test]
fn parses_every_timeout_form() {
let src = r#"
pipeline {
step seconds { command "x" timeout 90 }
step minutes { command "x" timeout "10m" }
step hours { command "x" timeout "2h" }
step unbound { command "x" timeout off }
step inherit { command "x" }
}
"#;
let cfg = parse(src).unwrap();
let by = |name: &str| cfg.steps.iter().find(|s| s.name == name).unwrap().timeout;
assert_eq!(by("seconds"), Some(Timeout::After(Duration::from_secs(90))));
assert_eq!(
by("minutes"),
Some(Timeout::After(Duration::from_secs(600)))
);
assert_eq!(by("hours"), Some(Timeout::After(Duration::from_secs(7200))));
assert_eq!(by("unbound"), Some(Timeout::Off));
assert_eq!(by("inherit"), None, "an absent field must stay absent");
assert_eq!(by("seconds").unwrap().describe(), "\"90s\"");
assert_eq!(by("minutes").unwrap().describe(), "\"10m\"");
assert_eq!(by("hours").unwrap().describe(), "\"2h\"");
assert_eq!(by("unbound").unwrap().describe(), "off");
}
#[test]
fn an_unquoted_duration_is_rejected_with_the_quoted_spelling() {
let err = parse("pipeline { step a { command \"x\" timeout 10m } }").unwrap_err();
assert!(err.message.contains("must be quoted"), "{err}");
assert!(err.message.contains("timeout \"10m\""), "{err}");
}
#[test]
fn rejects_meaningless_timeout_and_parallel_values() {
for src in [
"pipeline { step a { command \"x\" timeout 0 } }",
"pipeline { step a { command \"x\" timeout \"0m\" } }",
] {
let err = parse(src).unwrap_err();
assert!(err.message.contains("timeout off"), "{err}");
}
let err = parse("pipeline { step a { command \"x\" timeout \"soon\" } }").unwrap_err();
assert!(err.message.contains("invalid timeout"), "{err}");
let err = parse("pipeline { parallel 0 step a { command \"x\" } }").unwrap_err();
assert!(err.message.contains("at least 1"), "{err}");
}
#[test]
fn parses_pipeline_execution_settings() {
let src = r#"
pipeline {
timeout "5m"
parallel 3
step build { command "cargo build" }
}
"#;
let cfg = parse(src).unwrap();
assert_eq!(
cfg.execution.timeout,
Some(Timeout::After(Duration::from_secs(300)))
);
assert_eq!(cfg.execution.parallel, Some(3));
assert_eq!(cfg.steps.len(), 1);
let bare = parse("pipeline { step build { command \"x\" } }").unwrap();
assert_eq!(bare.execution, Default::default());
}
#[test]
fn unknown_pipeline_field_lists_the_alternatives() {
let err = parse("pipeline { workers 4 }").unwrap_err();
for expected in ["step", "use", "timeout", "parallel"] {
assert!(
err.message.contains(expected),
"{err} should list {expected}"
);
}
}
#[test]
fn parses_environment_and_deployment() {
let src = r#"
environment { image "rust:latest" }
deployment { target kubernetes replicas 3 }
pipeline { step build { command "cargo build" } }
"#;
let cfg = parse(src).unwrap();
assert_eq!(
cfg.environment.unwrap().image.as_deref(),
Some("rust:latest")
);
let dep = cfg.deployment.unwrap();
assert_eq!(dep.target.as_deref(), Some("kubernetes"));
assert_eq!(dep.replicas, Some(3));
}
}