use clap::Args;
use std::path::{Path, PathBuf};
const REGISTRY_PREFIX: &str = "quay.io/eval-containers/";
#[derive(Args)]
pub struct GenBakeArgs {
pub artifact: String,
#[arg(long)]
pub force: bool,
}
pub fn execute(args: GenBakeArgs) -> Result<(), String> {
let dir = PathBuf::from(&args.artifact);
let dockerfile = dir.join("Dockerfile");
if !dockerfile.exists() {
return Err(format!(
"{}/Dockerfile not found (gen-bake takes an artifact dir, not a category)",
args.artifact
));
}
let (category, name) = split_artifact(&dir)?;
let dockerfile_text = std::fs::read_to_string(&dockerfile)
.map_err(|e| format!("read {}: {e}", dockerfile.display()))?;
let deps = in_repo_deps(&dockerfile_text);
let takes_hf = dockerfile_text.contains("HF_TOKEN");
let content = render(&category, name, &deps, takes_hf);
let out = dir.join("docker-bake.hcl");
if out.exists() && !args.force {
return Err(format!(
"{} already exists; pass --force to overwrite",
out.display()
));
}
std::fs::write(&out, content).map_err(|e| format!("write {}: {e}", out.display()))?;
eprintln!("wrote {}", out.display());
Ok(())
}
fn split_artifact(dir: &Path) -> Result<(String, &str), String> {
let name = dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("invalid artifact path: {}", dir.display()))?;
let category = dir
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.ok_or_else(|| format!("artifact has no category parent: {}", dir.display()))?;
if !["core", "agents", "benchmarks", "models", "gateways"].contains(&category) {
return Err(format!(
"unknown category `{category}` (expected one of core/agents/benchmarks/models/gateways)"
));
}
Ok((category.to_string(), name))
}
fn in_repo_deps(text: &str) -> Vec<String> {
let mut deps: Vec<String> = Vec::new();
let push = |s: &str, deps: &mut Vec<String>| {
if s.starts_with(REGISTRY_PREFIX) {
let bare = s.split(':').next().unwrap_or(s).to_string();
if !deps.contains(&bare) {
deps.push(bare);
}
}
};
for raw in text.lines() {
let line = raw.trim();
if let Some(rest) = line.strip_prefix("FROM ") {
let mut tok = rest;
while tok.strip_prefix("--").is_some() {
let cut = tok.find(' ').map(|i| i + 1).unwrap_or(tok.len());
tok = &tok[cut..];
}
let image = tok.split_whitespace().next().unwrap_or("");
push(image, &mut deps);
continue;
}
if let Some(rest) = line.strip_prefix("COPY --from=") {
let image = rest.split_whitespace().next().unwrap_or("");
push(image, &mut deps);
}
}
deps
}
fn target_name_for(category: &str, name: &str) -> String {
match category {
"core" | "gateways" => name.replace('.', "_"),
"agents" => format!("agent-{}", name),
"benchmarks" => format!("benchmark-{}", name),
"models" => format!("model-{}", name.replace('.', "_")),
_ => name.to_string(),
}
}
fn ref_to_target(image_ref: &str) -> String {
let no_reg = &image_ref[REGISTRY_PREFIX.len()..];
let no_tag = no_reg.split(':').next().unwrap_or(no_reg);
let mut parts = no_tag.splitn(2, '/');
let cat = parts.next().unwrap_or("");
let n = parts.next().unwrap_or("");
target_name_for(cat, n)
}
fn render(category: &str, name: &str, deps: &[String], takes_hf: bool) -> String {
let target_name = target_name_for(category, name);
let mut out = String::new();
if takes_hf {
out.push_str("variable \"HF_TOKEN\" { default = \"\" }\n\n");
}
out.push_str(&format!("target \"{target_name}\" {{\n"));
out.push_str(&format!(" context = \"{category}/{name}\"\n"));
if !deps.is_empty() {
out.push_str(" contexts = {\n");
for dep in deps {
let key = dep.replace(REGISTRY_PREFIX, "${REGISTRY}/");
out.push_str(&format!(
" \"{key}\" = \"target:{}\"\n",
ref_to_target(dep)
));
}
out.push_str(" }\n");
}
if takes_hf {
out.push_str(" args = { HF_TOKEN = HF_TOKEN }\n");
}
out.push_str(&format!(
" tags = [\"${{REGISTRY}}/{category}/{name}:${{TAG}}\"]\n"
));
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leaf_core() {
let text = "FROM --platform=linux/amd64 python:3.12-slim\nRUN echo hi\n";
assert_eq!(in_repo_deps(text), Vec::<String>::new());
let out = render("core", "agent-base-python", &[], false);
assert!(out.contains("target \"agent-base-python\""));
assert!(out.contains("context = \"core/agent-base-python\""));
assert!(out.contains("tags = [\"${REGISTRY}/core/agent-base-python:${TAG}\"]"));
assert!(!out.contains("contexts"));
assert!(!out.contains("HF_TOKEN"));
}
#[test]
fn benchmark_with_hf_and_deps() {
let text = "FROM --platform=linux/amd64 python:3.12-slim\n\
COPY --from=quay.io/eval-containers/core/entrypoint:latest /eval-entrypoint.sh /eval-entrypoint.sh\n\
ARG HF_TOKEN\nRUN curl ... $HF_TOKEN\n";
let deps = in_repo_deps(text);
assert_eq!(deps, vec!["quay.io/eval-containers/core/entrypoint"]);
let takes_hf = text.contains("HF_TOKEN");
let out = render("benchmarks", "hle", &deps, takes_hf);
assert!(out.contains("variable \"HF_TOKEN\""));
assert!(out.contains("\"${REGISTRY}/core/entrypoint\" = \"target:entrypoint\""));
assert!(out.contains("args = { HF_TOKEN = HF_TOKEN }"));
}
#[test]
fn agent_with_in_repo_base() {
let text = "FROM quay.io/eval-containers/core/agent-base-python:latest\n";
let deps = in_repo_deps(text);
let out = render("agents", "openhands", &deps, false);
assert!(out.contains("target \"agent-openhands\""));
assert!(
out.contains("\"${REGISTRY}/core/agent-base-python\" = \"target:agent-base-python\"")
);
assert!(out.contains("tags = [\"${REGISTRY}/agents/openhands:${TAG}\"]"));
}
}