use a3s_box_core::error::{BoxError, Result};
use super::utils::{parse_duration_secs, parse_json_array, shell_split, unquote};
use super::{split_first_word, Instruction, RunCacheMount};
pub(super) fn parse_from(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: FROM requires an image argument",
line_num
)));
}
let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
let (image, alias) = if parts.len() >= 3 && parts[1].eq_ignore_ascii_case("AS") {
(parts[0].to_string(), Some(parts[2].trim().to_string()))
} else {
(parts[0].to_string(), None)
};
Ok(Instruction::From { image, alias })
}
pub(super) fn parse_run(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: RUN requires a command",
line_num
)));
}
if rest.starts_with('[') {
return Err(BoxError::BuildError(format!(
"Line {}: RUN exec form is not supported yet; use shell form",
line_num
)));
}
let (cache_mounts, command) = parse_run_options(rest, line_num)?;
Ok(Instruction::Run {
command: command.to_string(),
cache_mounts,
})
}
fn parse_run_options<'a>(rest: &'a str, line_num: usize) -> Result<(Vec<RunCacheMount>, &'a str)> {
let mut remaining = rest.trim_start();
let mut cache_mounts = Vec::new();
while remaining.starts_with("--") {
let (flag, tail) = split_first_word(remaining);
if let Some(spec) = flag.strip_prefix("--mount=") {
cache_mounts.push(parse_run_cache_mount(flag, spec, line_num)?);
} else {
return Err(BoxError::BuildError(format!(
"Line {}: RUN option '{}' is not supported yet; only BuildKit cache mounts (--mount=type=cache,...) are supported",
line_num, flag
)));
}
remaining = tail.trim_start();
}
if remaining.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: RUN requires a command after BuildKit options",
line_num
)));
}
Ok((cache_mounts, remaining))
}
fn parse_run_cache_mount(raw: &str, spec: &str, line_num: usize) -> Result<RunCacheMount> {
let mut mount_type = None;
let mut target = None;
for part in spec.split(',') {
let (key, value) = part.split_once('=').ok_or_else(|| {
BoxError::BuildError(format!(
"Line {}: Invalid RUN mount option '{}'",
line_num, raw
))
})?;
match key {
"type" => mount_type = Some(value),
"target" | "dst" | "destination" => target = Some(value),
_ => {}
}
}
if mount_type != Some("cache") {
return Err(BoxError::BuildError(format!(
"Line {}: RUN mount '{}' is not supported yet; only type=cache is supported",
line_num, raw
)));
}
let Some(target) = target.filter(|target| !target.is_empty()) else {
return Err(BoxError::BuildError(format!(
"Line {}: RUN cache mount '{}' requires a target= path",
line_num, raw
)));
};
if !target.starts_with('/') {
return Err(BoxError::BuildError(format!(
"Line {}: RUN cache mount target '{}' must be absolute",
line_num, target
)));
}
Ok(RunCacheMount {
raw: raw.to_string(),
target: target.to_string(),
})
}
pub(super) fn parse_copy(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: COPY requires source and destination",
line_num
)));
}
let (from, chown, remaining) = parse_copy_flags(rest, line_num)?;
if remaining.starts_with('[') {
return Err(BoxError::BuildError(format!(
"Line {}: COPY JSON array form is not supported yet",
line_num
)));
}
let parts: Vec<&str> = shell_split(remaining);
if parts.len() < 2 {
return Err(BoxError::BuildError(format!(
"Line {}: COPY requires at least one source and a destination",
line_num
)));
}
let dst = parts[parts.len() - 1].to_string();
let src: Vec<String> = parts[..parts.len() - 1]
.iter()
.map(|s| s.to_string())
.collect();
Ok(Instruction::Copy {
src,
dst,
from,
chown,
})
}
pub(super) fn parse_workdir(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: WORKDIR requires a path",
line_num
)));
}
Ok(Instruction::Workdir {
path: rest.to_string(),
})
}
pub(super) fn parse_env(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: ENV requires a key and value",
line_num
)));
}
let first_eq = rest.find('=');
let first_space = rest.find(char::is_whitespace);
let is_kv_form = match (first_eq, first_space) {
(Some(eq), Some(sp)) => eq < sp,
(Some(_), None) => true,
_ => false,
};
if is_kv_form {
let mut vars = Vec::new();
for token in tokenize_quoted(rest) {
match token.split_once('=') {
Some((key, value)) if !key.is_empty() => {
vars.push((key.to_string(), unquote(value)))
}
_ => {
return Err(BoxError::BuildError(format!(
"Line {}: invalid ENV token '{}' (expected KEY=VALUE)",
line_num, token
)))
}
}
}
return Ok(Instruction::Env { vars });
}
let (key, value) = split_first_word(rest);
Ok(Instruction::Env {
vars: vec![(key.to_string(), value.to_string())],
})
}
fn tokenize_quoted(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut has_content = false;
for c in s.chars() {
match quote {
Some(q) => {
current.push(c);
if c == q {
quote = None;
}
}
None => {
if c == '"' || c == '\'' {
quote = Some(c);
current.push(c);
has_content = true;
} else if c.is_whitespace() {
if has_content {
tokens.push(std::mem::take(&mut current));
has_content = false;
}
} else {
current.push(c);
has_content = true;
}
}
}
}
if has_content {
tokens.push(current);
}
tokens
}
pub(super) fn parse_entrypoint(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: ENTRYPOINT requires an argument",
line_num
)));
}
let exec = if rest.starts_with('[') {
parse_json_array(rest, line_num)?
} else {
vec!["/bin/sh".to_string(), "-c".to_string(), rest.to_string()]
};
Ok(Instruction::Entrypoint { exec })
}
pub(super) fn parse_cmd(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: CMD requires an argument",
line_num
)));
}
let exec = if rest.starts_with('[') {
parse_json_array(rest, line_num)?
} else {
vec!["/bin/sh".to_string(), "-c".to_string(), rest.to_string()]
};
Ok(Instruction::Cmd { exec })
}
pub(super) fn parse_expose(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: EXPOSE requires a port",
line_num
)));
}
let ports = rest
.split_whitespace()
.map(|p| {
if p.contains('/') {
p.to_string()
} else {
format!("{}/tcp", p)
}
})
.collect();
Ok(Instruction::Expose { ports })
}
pub(super) fn parse_label(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: LABEL requires key=value",
line_num
)));
}
let first_eq = rest.find('=');
let first_space = rest.find(char::is_whitespace);
let is_kv_form = match (first_eq, first_space) {
(Some(eq), Some(sp)) => eq < sp,
(Some(_), None) => true,
_ => false,
};
if is_kv_form {
let mut pairs = Vec::new();
for token in tokenize_quoted(rest) {
match token.split_once('=') {
Some((key, value)) if !key.is_empty() => {
pairs.push((key.to_string(), unquote(value)))
}
_ => {
return Err(BoxError::BuildError(format!(
"Line {}: invalid LABEL token '{}' (expected key=value)",
line_num, token
)))
}
}
}
return Ok(Instruction::Label { pairs });
}
let (key, value) = split_first_word(rest);
Ok(Instruction::Label {
pairs: vec![(key.to_string(), unquote(value))],
})
}
pub(super) fn parse_user(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: USER requires a username",
line_num
)));
}
Ok(Instruction::User {
user: rest.split_whitespace().next().unwrap_or(rest).to_string(),
})
}
pub(super) fn parse_arg(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: ARG requires a name",
line_num
)));
}
if let Some(eq_pos) = rest.find('=') {
let name = rest[..eq_pos].to_string();
let default = Some(unquote(&rest[eq_pos + 1..]));
Ok(Instruction::Arg { name, default })
} else {
Ok(Instruction::Arg {
name: rest.trim().to_string(),
default: None,
})
}
}
pub(super) fn parse_add(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: ADD requires source and destination",
line_num
)));
}
let (chown_from_flag, remaining) = parse_add_flags(rest, line_num)?;
if remaining.starts_with('[') {
return Err(BoxError::BuildError(format!(
"Line {}: ADD JSON array form is not supported yet",
line_num
)));
}
let parts: Vec<&str> = shell_split(remaining);
if parts.len() < 2 {
return Err(BoxError::BuildError(format!(
"Line {}: ADD requires at least one source and a destination",
line_num
)));
}
let dst = parts[parts.len() - 1].to_string();
let src: Vec<String> = parts[..parts.len() - 1]
.iter()
.map(|s| s.to_string())
.collect();
Ok(Instruction::Add {
src,
dst,
chown: chown_from_flag,
})
}
pub(super) fn parse_shell(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: SHELL requires a JSON array argument",
line_num
)));
}
if !rest.starts_with('[') {
return Err(BoxError::BuildError(format!(
"Line {}: SHELL must use JSON array form (e.g., SHELL [\"/bin/bash\", \"-c\"])",
line_num
)));
}
let exec = parse_json_array(rest, line_num)?;
if exec.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: SHELL requires at least one element",
line_num
)));
}
Ok(Instruction::Shell { exec })
}
pub(super) fn parse_stopsignal(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: STOPSIGNAL requires a signal",
line_num
)));
}
Ok(Instruction::StopSignal {
signal: rest.trim().to_string(),
})
}
pub(super) fn parse_healthcheck(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: HEALTHCHECK requires CMD or NONE",
line_num
)));
}
if rest.trim().eq_ignore_ascii_case("NONE") {
return Ok(Instruction::HealthCheck {
cmd: None,
interval: None,
timeout: None,
retries: None,
start_period: None,
});
}
let mut interval = None;
let mut timeout = None;
let mut retries = None;
let mut start_period = None;
let mut remaining = rest;
loop {
let trimmed = remaining.trim_start();
if trimmed.starts_with("--interval=") {
let (flag, after) = split_first_word(trimmed);
interval = Some(parse_duration_secs(
flag.strip_prefix("--interval=").unwrap_or("30s"),
line_num,
)?);
remaining = after;
} else if trimmed.starts_with("--timeout=") {
let (flag, after) = split_first_word(trimmed);
timeout = Some(parse_duration_secs(
flag.strip_prefix("--timeout=").unwrap_or("30s"),
line_num,
)?);
remaining = after;
} else if trimmed.starts_with("--retries=") {
let (flag, after) = split_first_word(trimmed);
let val = flag.strip_prefix("--retries=").unwrap_or("3");
retries = Some(val.parse::<u32>().map_err(|_| {
BoxError::BuildError(format!(
"Line {}: Invalid --retries value: {}",
line_num, val
))
})?);
remaining = after;
} else if trimmed.starts_with("--start-period=") {
let (flag, after) = split_first_word(trimmed);
start_period = Some(parse_duration_secs(
flag.strip_prefix("--start-period=").unwrap_or("0s"),
line_num,
)?);
remaining = after;
} else {
break;
}
}
let trimmed = remaining.trim_start();
let (keyword, cmd_rest) = split_first_word(trimmed);
if !keyword.eq_ignore_ascii_case("CMD") {
return Err(BoxError::BuildError(format!(
"Line {}: HEALTHCHECK expected CMD, got '{}'",
line_num, keyword
)));
}
if cmd_rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: HEALTHCHECK CMD requires a command",
line_num
)));
}
let cmd = if cmd_rest.starts_with('[') {
parse_json_array(cmd_rest, line_num)?
} else {
vec![
"/bin/sh".to_string(),
"-c".to_string(),
cmd_rest.to_string(),
]
};
Ok(Instruction::HealthCheck {
cmd: Some(cmd),
interval,
timeout,
retries,
start_period,
})
}
pub(super) fn parse_onbuild(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: ONBUILD requires an instruction",
line_num
)));
}
let inner = super::parse_instruction(rest, line_num)?;
if matches!(inner, Instruction::OnBuild { .. }) {
return Err(BoxError::BuildError(format!(
"Line {}: ONBUILD ONBUILD is not allowed",
line_num
)));
}
if matches!(inner, Instruction::From { .. }) {
return Err(BoxError::BuildError(format!(
"Line {}: ONBUILD FROM is not allowed",
line_num
)));
}
Ok(Instruction::OnBuild {
instruction: Box::new(inner),
})
}
pub(super) fn parse_volume(rest: &str, line_num: usize) -> Result<Instruction> {
if rest.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: VOLUME requires at least one path",
line_num
)));
}
let paths = if rest.starts_with('[') {
parse_json_array(rest, line_num)?
} else {
rest.split_whitespace().map(|s| s.to_string()).collect()
};
if paths.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: VOLUME requires at least one path",
line_num
)));
}
Ok(Instruction::Volume { paths })
}
fn parse_copy_flags(rest: &str, line_num: usize) -> Result<(Option<String>, Option<String>, &str)> {
let mut from = None;
let mut chown = None;
let mut remaining = rest;
loop {
let trimmed = remaining.trim_start();
if !trimmed.starts_with("--") {
return Ok((from, chown, trimmed));
}
let (flag, after) = split_first_word(trimmed);
if let Some(stage) = flag.strip_prefix("--from=") {
if stage.is_empty() {
return Err(BoxError::BuildError(format!(
"Line {}: COPY --from requires a stage name or index",
line_num
)));
}
if from.replace(stage.to_string()).is_some() {
return Err(BoxError::BuildError(format!(
"Line {}: COPY specifies --from more than once",
line_num
)));
}
remaining = after;
continue;
}
if let Some(owner) = flag.strip_prefix("--chown=") {
chown = Some(owner.to_string());
remaining = after;
continue;
}
return Err(BoxError::BuildError(format!(
"Line {}: COPY flag '{}' is not supported (supported: --from=<stage>, --chown=user[:group])",
line_num, flag
)));
}
}
fn parse_add_flags(rest: &str, line_num: usize) -> Result<(Option<String>, &str)> {
let mut chown = None;
let mut remaining = rest;
loop {
let trimmed = remaining.trim_start();
if !trimmed.starts_with("--") {
return Ok((chown, trimmed));
}
let (flag, after) = split_first_word(trimmed);
if let Some(owner) = flag.strip_prefix("--chown=") {
chown = Some(owner.to_string());
remaining = after;
continue;
}
return Err(BoxError::BuildError(format!(
"Line {}: ADD flag '{}' is not supported (supported: --chown=user[:group])",
line_num, flag
)));
}
}