use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use crate::agent::log_line::LogLine;
use crate::agent::task_logger::TaskLogger;
use crate::context::AppContext;
use crate::context::ContainerEngine;
use crate::context::ResolvedConfig;
use crate::context::docker_client::DockerClient;
use crate::docker::build_lock_manager::DockerBuildLockManager;
use crate::docker::composer::{ComposedDockerfile, DockerComposer, InlineLayerOverrides};
use crate::docker::layers::{DockerImageConfig, DockerLayerType};
pub struct BuildOptions<'a> {
pub no_cache: bool,
pub dry_run: bool,
pub build_root: Option<&'a std::path::Path>,
pub logger: &'a TaskLogger,
}
pub struct EnsureImageOptions<'a> {
pub stack: &'a str,
pub agent: &'a str,
pub project: Option<&'a str>,
pub build_root: Option<&'a std::path::Path>,
pub force_rebuild: bool,
pub logger: &'a TaskLogger,
pub resolved_config: Option<&'a ResolvedConfig>,
}
fn get_git_config_from_repo(build_root: Option<&Path>, key: &str) -> Result<String> {
let config_result = match build_root {
Some(repo_path) => {
let repo = git2::Repository::open(repo_path)
.with_context(|| format!("Failed to open repository at {}", repo_path.display()))?;
repo.config()
.with_context(|| "Failed to get repository config")?
.get_string(key)
}
None => {
git2::Config::open_default()
.with_context(|| "Failed to open global git config")?
.get_string(key)
}
};
match config_result {
Ok(value) => Ok(value),
Err(_) => {
#[cfg(test)]
match key {
"user.name" => return Ok("Test User".to_string()),
"user.email" => return Ok("test@example.com".to_string()),
_ => {}
}
Err(anyhow::anyhow!(
"Git config '{}' not set. Please configure git:\n\
git config --global user.name \"Your Name\"\n\
git config --global user.email \"your@email.com\"",
key
))
}
}
}
fn list_layers_of_type(layer_type: &DockerLayerType) -> Vec<String> {
let prefix = format!("{}/", layer_type);
crate::assets::embedded::list_dockerfiles()
.into_iter()
.filter_map(|path| path.strip_prefix(&prefix).map(|s| s.to_string()))
.collect()
}
pub struct DockerImageManager {
ctx: AppContext,
client: Arc<dyn DockerClient>,
docker_build_lock_manager: Arc<DockerBuildLockManager>,
composer: DockerComposer,
}
impl DockerImageManager {
pub fn new(
ctx: &AppContext,
client: Arc<dyn DockerClient>,
docker_build_lock_manager: Option<Arc<DockerBuildLockManager>>,
) -> Self {
let composer = DockerComposer::new();
Self {
ctx: ctx.clone(),
client,
docker_build_lock_manager: docker_build_lock_manager
.unwrap_or_else(|| Arc::new(DockerBuildLockManager::new())),
composer,
}
}
fn create_config(stack: &str, agent: &str, project: &str) -> DockerImageConfig {
DockerImageConfig::new(stack.to_string(), agent.to_string(), project.to_string())
}
fn extract_inline_overrides(
resolved_config: Option<&ResolvedConfig>,
stack: &str,
agent: &str,
) -> Option<InlineLayerOverrides> {
let config = resolved_config?;
let overrides = InlineLayerOverrides {
stack_setup: config.stack_config.get(stack).and_then(|c| c.setup.clone()),
agent_setup: config.agent_config.get(agent).and_then(|c| c.setup.clone()),
project_setup: config.setup.clone(),
};
if overrides.stack_setup.is_some()
|| overrides.agent_setup.is_some()
|| overrides.project_setup.is_some()
{
Some(overrides)
} else {
None
}
}
fn print_dry_run_output(
&self,
composed: &ComposedDockerfile,
stack: &str,
agent: &str,
project: &str,
) {
println!("# Resolved Dockerfile for image: {}", composed.image_tag);
println!("# Configuration: stack={stack}, agent={agent}, project={project}");
println!("# Layer sources:");
println!("# stack/{stack}: {}", composed.layer_sources.stack);
println!("# agent/{agent}: {}", composed.layer_sources.agent);
println!("# project/{project}: {}", composed.layer_sources.project);
println!();
println!("{}", composed.dockerfile_content);
if !composed.build_args.is_empty() {
println!("\n# Build arguments:");
for arg in &composed.build_args {
println!("# - {arg}");
}
}
}
fn validate_layers(
&self,
config: &DockerImageConfig,
overrides: Option<&InlineLayerOverrides>,
) -> Vec<crate::docker::layers::DockerLayer> {
config
.get_layers()
.into_iter()
.filter(|layer| {
if let Some(ovr) = overrides {
let has_override = match layer.layer_type {
DockerLayerType::Stack => ovr.stack_setup.is_some(),
DockerLayerType::Agent => ovr.agent_setup.is_some(),
DockerLayerType::Project => ovr.project_setup.is_some(),
_ => false,
};
if has_override {
return false;
}
}
crate::assets::embedded::get_dockerfile(&layer.to_string()).is_err()
})
.collect()
}
fn get_image_tag(
&self,
stack: &str,
agent: &str,
project: Option<&str>,
overrides: Option<&InlineLayerOverrides>,
) -> Result<(String, bool)> {
let project = project.unwrap_or("default");
let config = Self::create_config(stack, agent, project);
let missing_layers = self.validate_layers(&config, overrides);
if missing_layers.len() == 1
&& missing_layers[0].layer_type == DockerLayerType::Project
&& project != "default"
{
return Ok((format!("tsk/{stack}/{agent}/default"), true));
}
if let Some(layer) = missing_layers.first() {
match layer.layer_type {
DockerLayerType::Base => {
return Err(anyhow::anyhow!(
"Base layer is missing. This is a critical error - please reinstall tsk."
));
}
DockerLayerType::Stack => {
return Err(anyhow::anyhow!(
"Stack '{stack}' not found. Available stacks: {:?}",
list_layers_of_type(&DockerLayerType::Stack)
));
}
DockerLayerType::Agent => {
return Err(anyhow::anyhow!(
"Agent '{agent}' not found. Available agents: {:?}",
list_layers_of_type(&DockerLayerType::Agent)
));
}
DockerLayerType::Project => {
return Err(anyhow::anyhow!(
"Project layer '{project}' not found and default fallback failed"
));
}
}
}
Ok((format!("tsk/{stack}/{agent}/{project}"), false))
}
pub async fn ensure_image(&self, opts: &EnsureImageOptions<'_>) -> Result<String> {
let overrides =
Self::extract_inline_overrides(opts.resolved_config, opts.stack, opts.agent);
let (tag, used_fallback) =
self.get_image_tag(opts.stack, opts.agent, opts.project, overrides.as_ref())?;
if used_fallback {
opts.logger.log(LogLine::tsk_message(
"Note: Using default project layer as project-specific layer was not found",
));
}
if !opts.force_rebuild && self.image_exists(&tag).await? {
return Ok(tag);
}
let _lock = self
.docker_build_lock_manager
.acquire_build_lock(&tag, opts.logger)
.await;
opts.logger.log(LogLine::tsk_message(format!(
"Building Docker image: {}",
tag
)));
let actual_project = if used_fallback {
"default"
} else {
opts.project.unwrap_or("default")
};
self.build_image(
opts.stack,
opts.agent,
Some(actual_project),
&BuildOptions {
no_cache: false,
dry_run: false,
build_root: opts.build_root,
logger: opts.logger,
},
opts.resolved_config,
)
.await?;
Ok(tag)
}
pub async fn build_image(
&self,
stack: &str,
agent: &str,
project: Option<&str>,
options: &BuildOptions<'_>,
resolved_config: Option<&ResolvedConfig>,
) -> Result<String> {
let project = project.unwrap_or("default");
let overrides = Self::extract_inline_overrides(resolved_config, stack, agent);
let config = Self::create_config(stack, agent, project);
let composed = self
.composer
.compose(&config, overrides.as_ref())
.with_context(|| format!("Failed to compose Dockerfile for {}", config.image_tag()))?;
self.composer
.validate_dockerfile(&composed.dockerfile_content)
.with_context(|| "Dockerfile validation failed")?;
if options.dry_run {
self.print_dry_run_output(&composed, stack, agent, project);
} else {
let git_user_name = get_git_config_from_repo(options.build_root, "user.name")?;
let git_user_email = get_git_config_from_repo(options.build_root, "user.email")?;
let agent_version = if let Ok(agent_instance) =
crate::agent::AgentProvider::get_agent(agent, self.ctx.tsk_env())
{
Some(agent_instance.version())
} else {
None
};
self.build_docker_image(
&composed,
&git_user_name,
&git_user_email,
agent_version.as_deref(),
options,
)
.await?;
}
Ok(format!("tsk/{stack}/{agent}/{project}"))
}
async fn image_exists(&self, tag: &str) -> Result<bool> {
if cfg!(test) {
return Ok(true);
}
self.client
.image_exists(tag)
.await
.map_err(|e| anyhow::anyhow!(e))
}
async fn build_docker_image(
&self,
composed: &ComposedDockerfile,
git_user_name: &str,
git_user_email: &str,
agent_version: Option<&str>,
options: &BuildOptions<'_>,
) -> Result<()> {
let tar_archive = self
.create_tar_archive(composed, options.build_root)
.context("Failed to create tar archive for Docker build")?;
let mut build_args = std::collections::HashMap::new();
if composed.build_args.contains("GIT_USER_NAME") {
build_args.insert("GIT_USER_NAME".to_string(), git_user_name.to_string());
}
if composed.build_args.contains("GIT_USER_EMAIL") {
build_args.insert("GIT_USER_EMAIL".to_string(), git_user_email.to_string());
}
if let Some(version) = agent_version
&& composed.build_args.contains("TSK_AGENT_VERSION")
{
build_args.insert("TSK_AGENT_VERSION".to_string(), version.to_string());
}
if self.ctx.tsk_config().container_engine == ContainerEngine::Podman {
if std::env::var("TSK_CONTAINER").is_ok() {
for var in super::PROXY_ENV_VARS {
if let Ok(val) = std::env::var(var) {
build_args.insert(var.to_string(), val);
}
}
} else {
for var in super::PROXY_ENV_VARS {
build_args.insert(var.to_string(), String::new());
}
}
}
let mut options_builder = bollard::query_parameters::BuildImageOptionsBuilder::default();
options_builder = options_builder.dockerfile("Dockerfile.tsk");
options_builder = options_builder.t(&composed.image_tag);
options_builder = options_builder.nocache(options.no_cache);
options_builder = options_builder.buildargs(&build_args);
if self.ctx.tsk_config().container_engine == ContainerEngine::Podman {
options_builder = options_builder.networkmode("host");
}
let docker_options = options_builder.build();
let mut build_stream = self
.client
.build_image(docker_options, tar_archive)
.await
.map_err(|e| anyhow::anyhow!("Docker build failed: {e}"))?;
use futures_util::StreamExt;
let mut build_output = String::new();
while let Some(result) = build_stream.next().await {
match result {
Ok(line) => {
build_output.push_str(&line);
}
Err(e) => {
options.logger.log(LogLine::tsk_message(&build_output));
return Err(anyhow::anyhow!("Docker build failed: {e}"));
}
}
}
Ok(())
}
fn create_tar_archive(
&self,
composed: &ComposedDockerfile,
build_root: Option<&std::path::Path>,
) -> Result<Vec<u8>> {
use tar::Builder;
let mut tar_data = Vec::new();
{
let mut builder = Builder::new(&mut tar_data);
builder.mode(tar::HeaderMode::Deterministic);
let dockerfile_bytes = composed.dockerfile_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("Dockerfile.tsk")?;
header.set_size(dockerfile_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, dockerfile_bytes)?;
if let Some(build_root) = build_root {
builder.append_dir_all(".", build_root)?;
}
builder.finish()?;
}
Ok(tar_data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::task_logger::TaskLogger;
use crate::context::AppContext;
use std::sync::Arc;
use tempfile::TempDir;
fn create_test_manager() -> DockerImageManager {
use crate::test_utils::NoOpDockerClient;
let ctx = AppContext::builder().build();
DockerImageManager::new(&ctx, Arc::new(NoOpDockerClient), None)
}
#[test]
fn test_get_image_tag_success() {
let manager = create_test_manager();
let result = manager.get_image_tag("default", "claude", Some("default"), None);
assert!(result.is_ok());
let (tag, used_fallback) = result.unwrap();
assert_eq!(tag, "tsk/default/claude/default");
assert!(!used_fallback);
}
#[test]
fn test_get_image_tag_fallback() {
let manager = create_test_manager();
let result = manager.get_image_tag("default", "claude", Some("non-existent-project"), None);
assert!(result.is_ok());
let (tag, used_fallback) = result.unwrap();
assert_eq!(tag, "tsk/default/claude/default");
assert!(used_fallback);
}
#[test]
fn test_get_image_tag_missing_stack() {
let manager = create_test_manager();
let result = manager.get_image_tag("non-existent-stack", "claude", Some("default"), None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("Stack 'non-existent-stack' not found")
);
}
#[test]
fn test_get_image_tag_missing_agent() {
let manager = create_test_manager();
let result = manager.get_image_tag("default", "non-existent-agent", Some("default"), None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("Agent 'non-existent-agent' not found")
);
}
#[test]
fn test_get_image_tag_with_none_project() {
let manager = create_test_manager();
let result = manager.get_image_tag("default", "claude", None, None);
assert!(result.is_ok());
let (tag, used_fallback) = result.unwrap();
assert_eq!(tag, "tsk/default/claude/default");
assert!(!used_fallback);
}
#[test]
fn test_get_image_tag_with_config_defined_stack() {
let manager = create_test_manager();
let overrides = InlineLayerOverrides {
stack_setup: Some("RUN echo custom".to_string()),
..Default::default()
};
let result =
manager.get_image_tag("custom-stack", "claude", Some("default"), Some(&overrides));
assert!(result.is_ok());
let (tag, _) = result.unwrap();
assert_eq!(tag, "tsk/custom-stack/claude/default");
}
#[tokio::test]
async fn test_build_image_modes() {
let manager = create_test_manager();
let result = manager
.build_image(
"default",
"claude",
Some("default"),
&BuildOptions {
no_cache: false,
dry_run: true,
build_root: None,
logger: &TaskLogger::no_file(),
},
None,
)
.await;
assert!(result.is_ok(), "Dry run failed: {:?}", result.err());
let tag = result.unwrap();
assert_eq!(tag, "tsk/default/claude/default");
let result = manager
.build_image(
"default",
"claude",
Some("default"),
&BuildOptions {
no_cache: false,
dry_run: false,
build_root: None,
logger: &TaskLogger::no_file(),
},
None,
)
.await;
let tag = result.unwrap();
assert_eq!(tag, "tsk/default/claude/default");
}
#[test]
fn test_create_tar_archive_uses_tsk_dockerfile() {
let manager = create_test_manager();
let composed = ComposedDockerfile {
dockerfile_content: "FROM ubuntu:24.04\nRUN echo 'test'".to_string(),
build_args: std::collections::HashSet::new(),
image_tag: "tsk/test/test/test".to_string(),
layer_sources: Default::default(),
};
let tar_data = manager.create_tar_archive(&composed, None).unwrap();
use tar::Archive;
let mut archive = Archive::new(&tar_data[..]);
let entries = archive.entries().unwrap();
let mut found_dockerfile = false;
for entry in entries {
let entry = entry.unwrap();
let path = entry.path().unwrap();
if path.to_str().unwrap() == "Dockerfile.tsk" {
found_dockerfile = true;
break;
}
}
assert!(found_dockerfile, "Dockerfile.tsk not found in tar archive");
}
#[test]
fn test_create_tar_archive_with_build_root() {
let manager = create_test_manager();
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("Dockerfile"), "FROM node:18").unwrap();
let composed = ComposedDockerfile {
dockerfile_content: "FROM ubuntu:24.04\nRUN echo 'tsk'".to_string(),
build_args: std::collections::HashSet::new(),
image_tag: "tsk/test/test/test".to_string(),
layer_sources: Default::default(),
};
let tar_data = manager
.create_tar_archive(&composed, Some(temp_dir.path()))
.unwrap();
use tar::Archive;
let mut archive = Archive::new(&tar_data[..]);
let entries = archive.entries().unwrap();
let mut found_tsk_dockerfile = false;
let mut found_project_dockerfile = false;
let mut tsk_content = String::new();
let mut project_content = String::new();
for entry in entries {
let mut entry = entry.unwrap();
let path = entry.path().unwrap();
match path.to_str().unwrap() {
"Dockerfile.tsk" => {
found_tsk_dockerfile = true;
use std::io::Read;
entry.read_to_string(&mut tsk_content).unwrap();
}
"Dockerfile" => {
found_project_dockerfile = true;
use std::io::Read;
entry.read_to_string(&mut project_content).unwrap();
}
_ => {}
}
}
assert!(
found_tsk_dockerfile,
"Dockerfile.tsk not found in tar archive"
);
assert!(
found_project_dockerfile,
"Project Dockerfile not found in tar archive"
);
assert!(
tsk_content.contains("RUN echo 'tsk'"),
"TSK Dockerfile has wrong content"
);
assert!(
project_content.contains("FROM node:18"),
"Project Dockerfile has wrong content"
);
}
#[tokio::test]
async fn test_agent_version_in_build_args() {
let manager = create_test_manager();
let config = DockerImageConfig::new(
"default".to_string(),
"claude".to_string(),
"default".to_string(),
);
let composed = manager.composer.compose(&config, None).unwrap();
assert!(
composed.build_args.contains("TSK_AGENT_VERSION"),
"TSK_AGENT_VERSION should be in build args"
);
assert!(
composed
.dockerfile_content
.contains("ARG TSK_AGENT_VERSION"),
"Dockerfile should contain ARG TSK_AGENT_VERSION"
);
}
#[tokio::test]
async fn test_build_image_includes_agent_version() {
let manager = create_test_manager();
let result = manager
.build_image(
"default",
"claude",
Some("default"),
&BuildOptions {
no_cache: false,
dry_run: true,
build_root: None,
logger: &TaskLogger::no_file(),
},
None,
)
.await;
assert!(result.is_ok(), "Build should succeed with agent version");
}
#[tokio::test]
async fn test_build_lock_manager_integration() {
use crate::docker::build_lock_manager::DockerBuildLockManager;
use std::time::Duration;
use tokio::time::sleep;
let lock_manager = Arc::new(DockerBuildLockManager::new());
let lock1 = Arc::clone(&lock_manager);
let lock2 = Arc::clone(&lock_manager);
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
let order1 = Arc::clone(&order);
let order2 = Arc::clone(&order);
let task1 = tokio::spawn(async move {
let _guard = lock1
.acquire_build_lock("test-image", &TaskLogger::no_file())
.await;
order1.lock().unwrap().push(1);
sleep(Duration::from_millis(50)).await;
order1.lock().unwrap().push(2);
"task1_done"
});
let task2 = tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
let _guard = lock2
.acquire_build_lock("test-image", &TaskLogger::no_file())
.await;
order2.lock().unwrap().push(3);
sleep(Duration::from_millis(10)).await;
order2.lock().unwrap().push(4);
"task2_done"
});
let (result1, result2) = tokio::join!(task1, task2);
assert_eq!(result1.unwrap(), "task1_done");
assert_eq!(result2.unwrap(), "task2_done");
let final_order = order.lock().unwrap();
assert_eq!(
*final_order,
vec![1, 2, 3, 4],
"Tasks should execute serially due to lock"
);
}
#[tokio::test]
async fn test_parallel_ensure_image_different_images() {
use crate::docker::build_lock_manager::DockerBuildLockManager;
let lock_manager = Arc::new(DockerBuildLockManager::new());
use crate::test_utils::NoOpDockerClient;
let ctx1 = AppContext::builder().build();
let manager1 = DockerImageManager::new(
&ctx1,
Arc::new(NoOpDockerClient),
Some(lock_manager.clone()),
);
let ctx2 = AppContext::builder().build();
let manager2 = DockerImageManager::new(
&ctx2,
Arc::new(NoOpDockerClient),
Some(lock_manager.clone()),
);
let task1 = tokio::spawn(async move {
manager1
.ensure_image(&EnsureImageOptions {
stack: "rust",
agent: "claude",
project: Some("project1"),
build_root: None,
force_rebuild: false,
logger: &TaskLogger::no_file(),
resolved_config: None,
})
.await
});
let task2 = tokio::spawn(async move {
manager2
.ensure_image(&EnsureImageOptions {
stack: "python",
agent: "claude",
project: Some("project2"),
build_root: None,
force_rebuild: false,
logger: &TaskLogger::no_file(),
resolved_config: None,
})
.await
});
let (result1, result2) = tokio::join!(task1, task2);
assert!(result1.is_ok());
assert!(result2.is_ok());
let image1 = result1.unwrap().unwrap();
let image2 = result2.unwrap().unwrap();
assert_ne!(image1, image2);
}
#[test]
fn test_get_git_config_from_repo_with_repository() {
use crate::test_utils::git_test_utils::TestGitRepository;
let repo = TestGitRepository::new().unwrap();
repo.init().unwrap();
repo.run_git_command(&["config", "user.name", "Custom Repo User"])
.unwrap();
repo.run_git_command(&["config", "user.email", "repo@example.com"])
.unwrap();
let name = get_git_config_from_repo(Some(repo.path()), "user.name").unwrap();
assert_eq!(name, "Custom Repo User");
let email = get_git_config_from_repo(Some(repo.path()), "user.email").unwrap();
assert_eq!(email, "repo@example.com");
}
#[test]
fn test_get_git_config_from_repo_without_repository() {
let result = get_git_config_from_repo(None, "user.name");
assert!(result.is_ok());
let name = result.unwrap();
assert!(!name.is_empty());
let result = get_git_config_from_repo(None, "user.email");
assert!(result.is_ok());
let email = result.unwrap();
assert!(!email.is_empty());
}
#[test]
fn test_get_git_config_from_repo_unknown_key() {
let result = get_git_config_from_repo(None, "user.unknown");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not set"));
}
#[test]
fn test_extract_inline_overrides_with_config() {
use crate::context::tsk_config::{AgentConfig, StackConfig};
use std::collections::HashMap;
let config = ResolvedConfig {
setup: Some("RUN echo project-setup".to_string()),
stack_config: HashMap::from([(
"rust".to_string(),
StackConfig {
setup: Some("RUN cargo install nextest".to_string()),
},
)]),
agent_config: HashMap::from([(
"claude".to_string(),
AgentConfig {
setup: Some("RUN npm install -g tool".to_string()),
},
)]),
..Default::default()
};
let overrides =
DockerImageManager::extract_inline_overrides(Some(&config), "rust", "claude");
assert!(overrides.is_some());
let ovr = overrides.unwrap();
assert_eq!(
ovr.stack_setup,
Some("RUN cargo install nextest".to_string())
);
assert_eq!(ovr.agent_setup, Some("RUN npm install -g tool".to_string()));
assert_eq!(
ovr.project_setup,
Some("RUN echo project-setup".to_string())
);
}
#[test]
fn test_extract_inline_overrides_no_matching_stack_or_agent() {
use crate::context::tsk_config::StackConfig;
use std::collections::HashMap;
let config = ResolvedConfig {
stack_config: HashMap::from([(
"python".to_string(),
StackConfig {
setup: Some("RUN pip install numpy".to_string()),
},
)]),
..Default::default()
};
let overrides =
DockerImageManager::extract_inline_overrides(Some(&config), "rust", "claude");
assert!(overrides.is_none());
}
#[test]
fn test_extract_inline_overrides_none_config() {
let overrides = DockerImageManager::extract_inline_overrides(None, "rust", "claude");
assert!(overrides.is_none());
}
#[tokio::test]
async fn test_build_image_with_resolved_config() {
use crate::context::tsk_config::StackConfig;
use std::collections::HashMap;
let manager = create_test_manager();
let config = ResolvedConfig {
setup: Some("RUN echo from-config".to_string()),
stack_config: HashMap::from([(
"default".to_string(),
StackConfig {
setup: Some("RUN echo stack-from-config".to_string()),
},
)]),
..Default::default()
};
let result = manager
.build_image(
"default",
"claude",
Some("default"),
&BuildOptions {
no_cache: false,
dry_run: true,
build_root: None,
logger: &TaskLogger::no_file(),
},
Some(&config),
)
.await;
assert!(
result.is_ok(),
"Dry run with config failed: {:?}",
result.err()
);
}
}