use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Args;
use tonin_plugin::{
BuildBackend, BuildDryRunBackend, BuildValues, ConfigLoader, DockerBackend, Plan,
};
#[derive(Args)]
pub struct BuildArgs {
#[arg(long)]
pub service: Option<String>,
#[arg(long)]
pub tag: Option<String>,
#[arg(long)]
pub registry: Option<String>,
#[arg(long)]
pub push: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub workspace: Option<PathBuf>,
}
pub fn run(args: BuildArgs) -> Result<()> {
let root = args
.workspace
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap());
let backend: Box<dyn BuildBackend> = if args.dry_run {
Box::new(BuildDryRunBackend)
} else {
Box::new(DockerBackend)
};
let version = resolve_version(&root)?;
let plans = Plan::load_workspace(&root)?;
let plans_to_build: Vec<&Plan> = match &args.service {
Some(svc_name) => plans.iter().filter(|p| &p.name == svc_name).collect(),
None => plans.iter().collect(),
};
anyhow::ensure!(!plans_to_build.is_empty(), "no services found to build");
let mut build_results = Vec::new();
for plan in &plans_to_build {
let image_tag = args.tag.clone().unwrap_or_else(|| {
let registry = ConfigLoader::resolve_registry(
args.registry.as_deref(),
plan.image_registry.as_deref(),
None,
);
format!("{}/{}:{}", registry, plan.name, version)
});
let dockerfile = infer_dockerfile(&root, &plan.name)?;
let context = infer_context(&root, &plan.name, &dockerfile);
let values = BuildValues {
service: plan.name.clone(),
image_tag: image_tag.clone(),
dockerfile: dockerfile.clone(),
context: context.clone(),
};
println!("▶ building {}", plan.name);
let build_result = backend.build(&values)?;
build_results.push(build_result);
if args.push {
println!(" pushing {}", image_tag);
backend.push(&image_tag)?;
}
}
println!("✓ {} service(s) built successfully", plans_to_build.len());
Ok(())
}
fn resolve_version(workspace: &Path) -> Result<String> {
let version_path = workspace.join("VERSION");
if version_path.exists() {
let content = std::fs::read_to_string(&version_path)?.trim().to_string();
if !content.is_empty() {
return Ok(content);
}
}
let output = std::process::Command::new("git")
.args(["describe", "--tags", "--always"])
.current_dir(workspace)
.output()
.map_err(|e| anyhow::anyhow!("failed to run git describe: {e}"))?;
anyhow::ensure!(output.status.success(), "git describe failed");
let version = String::from_utf8(output.stdout)?.trim().to_string();
anyhow::ensure!(
!version.is_empty(),
"could not resolve version from VERSION file or git"
);
Ok(version)
}
fn infer_dockerfile(workspace: &Path, service: &str) -> Result<String> {
let candidates = vec![
format!("services/{}/Dockerfile", service),
format!("{}/Dockerfile", service),
format!("examples/{}/Dockerfile", service),
];
for candidate in &candidates {
let path = workspace.join(candidate);
if path.exists() {
return Ok(candidate.clone());
}
}
anyhow::bail!(
"no Dockerfile found for service '{}' in standard locations (tried: {})",
service,
candidates.join(", ")
)
}
fn infer_context(workspace: &Path, service: &str, dockerfile: &str) -> String {
if dockerfile.starts_with("examples/") {
return ".".to_string();
}
let candidates = vec![format!("services/{}", service), service.to_string()];
for candidate in candidates {
let path = workspace.join(&candidate);
if path.exists() && path.is_dir() {
return candidate;
}
}
service.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_resolution_missing_workspace() {
let result = resolve_version(Path::new("/nonexistent/path/that/does/not/exist"));
assert!(result.is_err());
}
#[test]
fn test_dockerfile_inference_missing_file() {
let result = infer_dockerfile(Path::new("/tmp"), "nonexistent-svc-xyz");
assert!(
result.is_err(),
"should fail when no Dockerfile found for service"
);
}
#[test]
fn test_context_inference() {
let context = infer_context(Path::new("/tmp"), "test-svc", "test-svc/Dockerfile");
assert!(
context.contains("test-svc"),
"Context should contain service name"
);
}
#[test]
fn test_context_inference_examples_uses_workspace_root() {
let context = infer_context(
Path::new("/tmp"),
"users-service",
"examples/users-service/Dockerfile",
);
assert_eq!(context, ".");
}
#[test]
fn test_build_values_construction() {
let values = BuildValues {
service: "orders-service".to_string(),
image_tag: "ghcr.io/myorg/orders-service:1.2.3".to_string(),
dockerfile: "services/orders-service/Dockerfile".to_string(),
context: "services/orders-service".to_string(),
};
assert_eq!(values.service, "orders-service");
assert_eq!(values.image_tag, "ghcr.io/myorg/orders-service:1.2.3");
assert!(values.dockerfile.contains("Dockerfile"));
}
}