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};
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
)));
}
let command = if rest.starts_with('[') {
let parts = parse_json_array(rest, line_num)?;
parts.join(" ")
} else {
rest.to_string()
};
Ok(Instruction::Run { command })
}
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, remaining) = if rest.starts_with("--from=") {
let (flag, after) = split_first_word(rest);
let stage = flag.strip_prefix("--from=").unwrap_or("").to_string();
(Some(stage), after)
} else {
(None, rest)
};
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 })
}
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
)));
}
if let Some(eq_pos) = rest.find('=') {
let space_pos = rest.find(char::is_whitespace);
if space_pos.is_none_or(|sp| eq_pos < sp) {
let key = rest[..eq_pos].to_string();
let value = unquote(&rest[eq_pos + 1..]);
return Ok(Instruction::Env { key, value });
}
}
let (key, value) = split_first_word(rest);
Ok(Instruction::Env {
key: key.to_string(),
value: value.to_string(),
})
}
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
)));
}
Ok(Instruction::Expose {
port: rest.split_whitespace().next().unwrap_or(rest).to_string(),
})
}
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
)));
}
if let Some(eq_pos) = rest.find('=') {
let key = rest[..eq_pos].trim().to_string();
let value = unquote(rest[eq_pos + 1..].trim());
Ok(Instruction::Label { key, value })
} else {
let (key, value) = split_first_word(rest);
Ok(Instruction::Label {
key: key.to_string(),
value: 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, remaining) = if rest.starts_with("--chown=") {
let (flag, after) = split_first_word(rest);
let user = flag.strip_prefix("--chown=").unwrap_or("").to_string();
(Some(user), after)
} else {
(None, rest)
};
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 })
}
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 })
}