mod common;
use std::path::Path;
use std::time::Duration;
use tokio::time::timeout;
use outrig::config::Config;
use outrig::error::OutrigError;
use outrig_cli::error::CliError;
use outrig_cli::image_setup::add::run_with;
use outrig_cli::init::repo::resolve_or_bootstrap;
use common::scripted_prompt;
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
fn seed_repo(root: &Path, initial_config: &str) {
let cfg_dir = root.join(".agents/outrig");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(cfg_dir.join("config.toml"), initial_config).unwrap();
}
#[tokio::test]
async fn defaults_write_dockerfile_and_config_block() {
let tmp = tempfile::tempdir().unwrap();
seed_repo(tmp.path(), "");
let script = b"\n\n\n";
let (mut prompt, _stderr) = scripted_prompt(script).await;
timeout(
TEST_TIMEOUT,
run_with(tmp.path(), Some("coding".to_string()), false, &mut prompt),
)
.await
.expect("run_with must not hang")
.expect("run_with must succeed");
let dockerfile_path = tmp.path().join(".agents/outrig/images/coding/Dockerfile");
let dockerfile = std::fs::read_to_string(&dockerfile_path).unwrap();
assert!(
dockerfile.starts_with("FROM docker.io/library/debian:bookworm-slim"),
"unexpected Dockerfile:\n{dockerfile}",
);
assert!(dockerfile.contains("@modelcontextprotocol/server-filesystem"));
assert!(
dockerfile
.trim_end()
.ends_with("CMD [\"sleep\", \"infinity\"]"),
);
let cfg_text = std::fs::read_to_string(tmp.path().join(".agents/outrig/config.toml")).unwrap();
assert!(cfg_text.contains("[images.coding]"), "{cfg_text}");
assert!(
cfg_text.contains("dockerfile = \".agents/outrig/images/coding/Dockerfile\""),
"{cfg_text}",
);
assert!(cfg_text.contains("[images.coding.mcp]"));
assert!(
cfg_text.contains("fs = { command = [\"mcp-server-filesystem\", \"/workspace\"] }"),
"{cfg_text}",
);
let cfg = Config::load_from_str(&cfg_text).expect("config must parse");
cfg.validate(None).expect("config must validate");
}
#[tokio::test]
async fn refuses_when_dockerfile_already_exists() {
let tmp = tempfile::tempdir().unwrap();
seed_repo(tmp.path(), "");
let dockerfile_path = tmp.path().join(".agents/outrig/images/coding/Dockerfile");
std::fs::create_dir_all(dockerfile_path.parent().unwrap()).unwrap();
std::fs::write(&dockerfile_path, "# stale\n").unwrap();
let (mut prompt, _stderr) = scripted_prompt(b"").await;
let err = timeout(
TEST_TIMEOUT,
run_with(tmp.path(), Some("coding".to_string()), false, &mut prompt),
)
.await
.expect("run_with must not hang")
.expect_err("run_with must error when Dockerfile exists and force=false");
let msg = format!("{err}");
assert!(
msg.contains("already exists") && msg.contains("--force"),
"unexpected error: {msg}",
);
assert_eq!(
std::fs::read_to_string(&dockerfile_path).unwrap(),
"# stale\n"
);
}
#[tokio::test]
async fn refuses_when_config_block_already_exists() {
let tmp = tempfile::tempdir().unwrap();
seed_repo(
tmp.path(),
"[images.coding]\n\
dockerfile = \".agents/outrig/images/coding/Dockerfile\"\n\
context = \".agents/outrig/images/coding\"\n\
\n\
[images.coding.mcp]\n",
);
let (mut prompt, _stderr) = scripted_prompt(b"").await;
let err = timeout(
TEST_TIMEOUT,
run_with(tmp.path(), Some("coding".to_string()), false, &mut prompt),
)
.await
.expect("run_with must not hang")
.expect_err("run_with must error when block exists and force=false");
let msg = format!("{err}");
assert!(
msg.contains("[images.coding]") && msg.contains("--force"),
"unexpected error: {msg}",
);
}
#[tokio::test]
async fn force_replaces_both_atomically() {
let tmp = tempfile::tempdir().unwrap();
seed_repo(
tmp.path(),
"[images.coding]\n\
dockerfile = \"old/Dockerfile\"\n\
context = \"old\"\n\
\n\
[images.coding.mcp]\n\
fs = { command = [\"old-cmd\"] }\n",
);
let dockerfile_path = tmp.path().join(".agents/outrig/images/coding/Dockerfile");
std::fs::create_dir_all(dockerfile_path.parent().unwrap()).unwrap();
std::fs::write(&dockerfile_path, "# stale dockerfile\n").unwrap();
let script = b"\n\n\n"; let (mut prompt, _stderr) = scripted_prompt(script).await;
timeout(
TEST_TIMEOUT,
run_with(tmp.path(), Some("coding".to_string()), true, &mut prompt),
)
.await
.expect("run_with must not hang")
.expect("run_with --force must succeed");
let dockerfile = std::fs::read_to_string(&dockerfile_path).unwrap();
assert!(
!dockerfile.contains("stale"),
"Dockerfile not replaced:\n{dockerfile}",
);
assert!(dockerfile.starts_with("FROM docker.io/library/debian:bookworm-slim"));
let cfg_text = std::fs::read_to_string(tmp.path().join(".agents/outrig/config.toml")).unwrap();
assert!(
!cfg_text.contains("old/Dockerfile"),
"old [images.coding] not replaced:\n{cfg_text}",
);
assert!(
!cfg_text.contains("old-cmd"),
"old mcp entry not replaced:\n{cfg_text}",
);
assert!(
cfg_text.contains("dockerfile = \".agents/outrig/images/coding/Dockerfile\""),
"{cfg_text}",
);
}
#[tokio::test]
async fn fallback_yes_bootstraps_repo_config() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().join("myproj");
std::fs::create_dir_all(&cwd).unwrap();
let global = tmp.path().join("global.toml");
let script = b"\n\n\n\n\n\n\n\n\n";
let (mut prompt, _stderr) = scripted_prompt(script).await;
let mut hf = common::StubHfTreeFetcher::with_files(Vec::<&str>::new());
timeout(TEST_TIMEOUT, async {
let (repo_root, bootstrapped_name) =
resolve_or_bootstrap(&cwd, &global, &mut prompt, &mut hf).await?;
run_with(&repo_root, bootstrapped_name, false, &mut prompt).await
})
.await
.expect("fallback flow must not hang")
.expect("fallback flow must succeed");
let cfg_path = cwd.join(".agents/outrig/config.toml");
let dockerfile = cwd.join(".agents/outrig/images/myproj-standard/Dockerfile");
assert!(cfg_path.is_file(), "repo config not bootstrapped");
assert!(dockerfile.is_file(), "container Dockerfile not written");
let cfg = Config::load_from_str(&std::fs::read_to_string(&cfg_path).unwrap())
.expect("repo config must parse");
assert_eq!(cfg.default_image.as_deref(), Some("myproj-standard"));
assert_eq!(cfg.default_agent.as_deref(), Some("coder"));
assert!(cfg.images.contains_key("myproj-standard"));
assert!(cfg.agents.contains_key("coder"));
}
#[tokio::test]
async fn fallback_no_returns_no_repo_config() {
let tmp = tempfile::tempdir().unwrap();
let global = tmp.path().join("global.toml");
let script = b"n\n";
let (mut prompt, _stderr) = scripted_prompt(script).await;
let mut hf = common::StubHfTreeFetcher::with_files(Vec::<&str>::new());
let err = timeout(
TEST_TIMEOUT,
resolve_or_bootstrap(tmp.path(), &global, &mut prompt, &mut hf),
)
.await
.expect("fallback must not hang")
.expect_err("declining the prompt must error");
assert!(
matches!(err, CliError::Outrig(OutrigError::NoRepoConfig)),
"expected NoRepoConfig, got: {err:?}"
);
assert!(!tmp.path().join(".agents").exists());
}
#[tokio::test]
async fn force_preserves_unrelated_blocks_and_comments() {
let tmp = tempfile::tempdir().unwrap();
let initial = "# top-level comment\n\
default-image = \"coding\"\n\
\n\
[images.coding]\n\
# inline comment for coding\n\
dockerfile = \"old/Dockerfile\"\n\
context = \"old\"\n\
\n\
[images.coding.mcp]\n\
\n\
[images.planning]\n\
dockerfile = \".agents/outrig/images/planning/Dockerfile\"\n\
context = \".agents/outrig/images/planning\"\n\
\n\
[images.planning.mcp]\n";
seed_repo(tmp.path(), initial);
let script = b"\n\n\n";
let (mut prompt, _stderr) = scripted_prompt(script).await;
timeout(
TEST_TIMEOUT,
run_with(tmp.path(), Some("coding".to_string()), true, &mut prompt),
)
.await
.expect("run_with must not hang")
.expect("run_with --force must succeed");
let cfg_text = std::fs::read_to_string(tmp.path().join(".agents/outrig/config.toml")).unwrap();
assert!(
cfg_text.contains("# top-level comment"),
"top-level comment lost:\n{cfg_text}",
);
assert!(
cfg_text.contains("default-image = \"coding\""),
"default-image key lost:\n{cfg_text}",
);
assert!(
cfg_text.contains("[images.planning]"),
"unrelated [images.planning] block lost:\n{cfg_text}",
);
assert!(
cfg_text.contains("dockerfile = \".agents/outrig/images/coding/Dockerfile\""),
"replaced [images.coding] missing new dockerfile path:\n{cfg_text}",
);
}