use anyhow::Result;
use std::path::Path;
pub struct SetupOutput {
pub messages: Vec<String>,
}
pub struct SetupDockerOutput {
pub messages: Vec<String>,
}
#[allow(dead_code)]
enum WriteAction {
Created,
Unchanged,
Replaced,
InitWritten,
Skipped,
}
fn write_default(path: &Path, content: &str, label: &str, messages: &mut Vec<String>) -> Result<WriteAction> {
if !path.exists() {
std::fs::write(path, content)?;
messages.push(format!("Created {label}"));
return Ok(WriteAction::Created);
}
let existing = std::fs::read_to_string(path)?;
if existing == content {
return Ok(WriteAction::Unchanged);
}
let init_path = init_path_for(path);
std::fs::write(&init_path, content)?;
messages.push(format!("{label} differs from default — wrote {label}.init for comparison"));
Ok(WriteAction::InitWritten)
}
fn init_path_for(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".init");
path.with_file_name(name)
}
pub fn setup(root: &Path, name: Option<&str>, description: Option<&str>, username: Option<&str>) -> Result<SetupOutput> {
let mut messages: Vec<String> = Vec::new();
let tickets_dir = root.join("tickets");
if !tickets_dir.exists() {
std::fs::create_dir_all(&tickets_dir)?;
messages.push("Created tickets/".to_string());
}
write_default(
&tickets_dir.join("EPIC.md"),
EPIC_MD_PLACEHOLDER,
"tickets/EPIC.md",
&mut messages,
)?;
let apm_dir = root.join(".apm");
std::fs::create_dir_all(&apm_dir)?;
let local_toml = apm_dir.join("local.toml");
let has_git_host = {
let config_path = apm_dir.join("config.toml");
config_path.exists() && crate::config::Config::load(root)
.map(|cfg| cfg.git_host.provider.is_some())
.unwrap_or(false)
};
if !has_git_host && !local_toml.exists() {
if let Some(u) = username {
if !u.is_empty() {
write_local_toml(&apm_dir, u)?;
messages.push("Created .apm/local.toml".to_string());
}
}
}
let effective_username = username.unwrap_or("");
let config_path = apm_dir.join("config.toml");
if !config_path.exists() {
let default_name = name.unwrap_or_else(|| {
root.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
});
let effective_description = description.unwrap_or("");
let collaborators: Vec<&str> = if effective_username.is_empty() {
vec![]
} else {
vec![effective_username]
};
let branch = detect_default_branch(root);
std::fs::write(&config_path, default_config(default_name, effective_description, &branch, &collaborators))?;
messages.push("Created .apm/config.toml".to_string());
} else {
let existing = std::fs::read_to_string(&config_path)?;
if let Ok(val) = existing.parse::<toml::Value>() {
let n = val.get("project")
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("project");
let d = val.get("project")
.and_then(|p| p.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("");
let b = val.get("project")
.and_then(|p| p.get("default_branch"))
.and_then(|v| v.as_str())
.unwrap_or("main");
let collab_owned: Vec<String> = val
.get("project")
.and_then(|p| p.get("collaborators"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
let collabs: Vec<&str> = collab_owned.iter().map(|s| s.as_str()).collect();
write_default(&config_path, &default_config(n, d, b, &collabs), ".apm/config.toml", &mut messages)?;
}
}
write_default(&apm_dir.join("workflow.toml"), default_workflow_toml(), ".apm/workflow.toml", &mut messages)?;
write_default(&apm_dir.join("ticket.toml"), default_ticket_toml(), ".apm/ticket.toml", &mut messages)?;
migrate_flat_agent_files(root, &apm_dir, &mut messages)?;
let agents_default_dir = apm_dir.join("agents/default");
std::fs::create_dir_all(&agents_default_dir)
.map_err(|e| anyhow::anyhow!("cannot create {}: {e}", agents_default_dir.display()))?;
write_default(&agents_default_dir.join("agents.md"), default_agents_md(), ".apm/agents/default/agents.md", &mut messages)?;
write_default(&agents_default_dir.join("apm.spec-writer.md"), include_str!("default/agents/default/apm.spec-writer.md"), ".apm/agents/default/apm.spec-writer.md", &mut messages)?;
write_default(&agents_default_dir.join("apm.worker.md"), include_str!("default/agents/default/apm.worker.md"), ".apm/agents/default/apm.worker.md", &mut messages)?;
let agents_claude_dir = apm_dir.join("agents/claude");
std::fs::create_dir_all(&agents_claude_dir)
.map_err(|e| anyhow::anyhow!("cannot create {}: {e}", agents_claude_dir.display()))?;
write_default(
&agents_claude_dir.join("apm.spec-writer.md"),
include_str!("default/agents/claude/apm.spec-writer.md"),
".apm/agents/claude/apm.spec-writer.md",
&mut messages,
)?;
write_default(
&agents_claude_dir.join("apm.worker.md"),
include_str!("default/agents/claude/apm.worker.md"),
".apm/agents/claude/apm.worker.md",
&mut messages,
)?;
ensure_claude_md(root, ".apm/agents/default/agents.md", &mut messages)?;
let gitignore = root.join(".gitignore");
let wt_pattern = crate::config::Config::load(root)
.ok()
.and_then(|c| worktree_gitignore_pattern(&c.worktrees.dir));
ensure_gitignore(&gitignore, wt_pattern.as_deref(), &mut messages)?;
ensure_gitattributes(&root.join(".gitattributes"), &mut messages)?;
maybe_initial_commit(root, &mut messages)?;
ensure_worktrees_dir(root, &mut messages)?;
Ok(SetupOutput { messages })
}
fn migrate_flat_agent_files(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
let agents_default_dir = apm_dir.join("agents/default");
std::fs::create_dir_all(&agents_default_dir)?;
let moves = [
("agents.md", "agents.md"),
("apm.spec-writer.md", "apm.spec-writer.md"),
("apm.worker.md", "apm.worker.md"),
("style.md", "style.md"),
];
for (old_name, new_name) in &moves {
let old_path = apm_dir.join(old_name);
let new_path = agents_default_dir.join(new_name);
if old_path.exists() && !new_path.exists() {
std::fs::rename(&old_path, &new_path)?;
messages.push(format!("Moved .apm/{old_name} → .apm/agents/default/{new_name}"));
}
}
let path_rewrites: &[(&str, &str)] = &[
("@.apm/agents.md", "@.apm/agents/default/agents.md"),
("@.apm/style.md", "@.apm/agents/default/style.md"),
];
let claude_path = root.join("CLAUDE.md");
if claude_path.exists() {
let contents = std::fs::read_to_string(&claude_path)?;
let mut updated = contents.clone();
for (old, new) in path_rewrites {
updated = updated.replace(old, new);
}
if updated != contents {
std::fs::write(&claude_path, &updated)?;
messages.push("Updated CLAUDE.md (agent file paths → agents/default/)".to_string());
}
}
let instructions_rewrites: &[(&str, &str)] = &[
(".apm/agents.md", ".apm/agents/default/agents.md"),
(".apm/apm.spec-writer.md", ".apm/agents/default/apm.spec-writer.md"),
(".apm/apm.worker.md", ".apm/agents/default/apm.worker.md"),
(".apm/style.md", ".apm/agents/default/style.md"),
];
let config_path = apm_dir.join("config.toml");
if config_path.exists() {
let contents = std::fs::read_to_string(&config_path)?;
let mut updated = contents.clone();
for (old, new) in instructions_rewrites {
updated = updated.replace(old, new);
}
if updated != contents {
std::fs::write(&config_path, &updated)?;
messages.push("Updated .apm/config.toml (instructions paths → agents/default/)".to_string());
}
}
let workflow_path = apm_dir.join("workflow.toml");
if workflow_path.exists() {
let contents = std::fs::read_to_string(&workflow_path)?;
let mut updated = contents.clone();
for (old, new) in instructions_rewrites {
updated = updated.replace(old, new);
}
if updated != contents {
std::fs::write(&workflow_path, &updated)?;
messages.push("Updated .apm/workflow.toml (instructions paths → agents/default/)".to_string());
}
}
Ok(())
}
pub fn migrate(root: &Path) -> Result<Vec<String>> {
let mut messages: Vec<String> = Vec::new();
let apm_dir = root.join(".apm");
let new_config = apm_dir.join("config.toml");
if new_config.exists() {
messages.push("Already migrated.".to_string());
return Ok(messages);
}
let old_config = root.join("apm.toml");
let old_agents = root.join("apm.agents.md");
if !old_config.exists() && !old_agents.exists() {
messages.push("Nothing to migrate.".to_string());
return Ok(messages);
}
std::fs::create_dir_all(&apm_dir)?;
if old_config.exists() {
std::fs::rename(&old_config, &new_config)?;
messages.push("Moved apm.toml → .apm/config.toml".to_string());
}
if old_agents.exists() {
let new_agents = apm_dir.join("agents.md");
std::fs::rename(&old_agents, &new_agents)?;
messages.push("Moved apm.agents.md → .apm/agents.md".to_string());
}
let claude_path = root.join("CLAUDE.md");
if claude_path.exists() {
let contents = std::fs::read_to_string(&claude_path)?;
if contents.contains("@apm.agents.md") {
let updated = contents.replace("@apm.agents.md", "@.apm/agents.md");
std::fs::write(&claude_path, updated)?;
messages.push("Updated CLAUDE.md (@apm.agents.md → @.apm/agents.md)".to_string());
}
}
Ok(messages)
}
pub fn detect_default_branch(root: &Path) -> String {
crate::git_util::current_branch(root)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "main".to_string())
}
pub fn worktree_gitignore_pattern(dir: &Path) -> Option<String> {
let s = dir.to_string_lossy();
if s.starts_with('/') || s.starts_with("..") {
return None;
}
Some(format!("/{s}/"))
}
pub fn ensure_gitignore(path: &Path, worktree_pattern: Option<&str>, messages: &mut Vec<String>) -> Result<()> {
let static_entries = [".apm/local.toml", ".apm/epics.toml", ".apm/*.init", ".apm/sessions.json", ".apm/credentials.json"];
let mut entries: Vec<&str> = static_entries.to_vec();
let owned_pattern;
if let Some(p) = worktree_pattern {
entries.push("# apm worktrees");
owned_pattern = p.to_owned();
entries.push(&owned_pattern);
}
if path.exists() {
let mut contents = std::fs::read_to_string(path)?;
let mut changed = false;
for entry in &entries {
if !contents.contains(entry) {
if !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(entry);
contents.push('\n');
changed = true;
}
}
if changed {
std::fs::write(path, &contents)?;
messages.push("Updated .gitignore".to_string());
}
} else {
std::fs::write(path, entries.join("\n") + "\n")?;
messages.push("Created .gitignore".to_string());
}
Ok(())
}
pub fn ensure_gitattributes(path: &Path, messages: &mut Vec<String>) -> Result<()> {
let entry = "tickets/EPIC.md merge=ours";
if path.exists() {
let mut contents = std::fs::read_to_string(path)?;
if !contents.contains(entry) {
if !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(entry);
contents.push('\n');
std::fs::write(path, &contents)?;
messages.push("Updated .gitattributes".to_string());
}
} else {
std::fs::write(path, format!("{entry}\n"))?;
messages.push("Created .gitattributes".to_string());
}
Ok(())
}
fn ensure_claude_md(root: &Path, agents_path: &str, messages: &mut Vec<String>) -> Result<()> {
let import_line = format!("@{agents_path}");
let claude_path = root.join("CLAUDE.md");
if claude_path.exists() {
let contents = std::fs::read_to_string(&claude_path)?;
if contents.contains(&import_line) {
return Ok(());
}
std::fs::write(&claude_path, format!("{import_line}\n\n{contents}"))?;
messages.push(format!("Updated CLAUDE.md (added {import_line} import)."));
} else {
std::fs::write(&claude_path, format!("{import_line}\n"))?;
messages.push("Created CLAUDE.md.".to_string());
}
Ok(())
}
const EPIC_MD_PLACEHOLDER: &str = "\
# EPIC.md — per-epic context document
Each epic branch writes its own `tickets/EPIC.md` with the epic title and any
notes added by the supervisor. APM reads it from the epic branch to build the
context bundle injected into worker prompts for tickets that belong to that epic.
The copy on `main` is this placeholder and is never read by APM at runtime.
The `tickets/EPIC.md merge=ours` rule in `.gitattributes` prevents merge
conflicts when multiple epics are open simultaneously.
";
fn default_agents_md() -> &'static str {
include_str!("default/agents/default/agents.md")
}
#[cfg(target_os = "macos")]
fn default_log_file(name: &str) -> String {
format!("~/Library/Logs/apm/{name}.log")
}
#[cfg(not(target_os = "macos"))]
fn default_log_file(name: &str) -> String {
format!("~/.local/state/apm/{name}.log")
}
fn toml_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn default_config(name: &str, description: &str, default_branch: &str, collaborators: &[&str]) -> String {
let log_file = default_log_file(name);
let name = toml_escape(name);
let description = toml_escape(description);
let default_branch = toml_escape(default_branch);
let log_file = toml_escape(&log_file);
let collaborators_line = {
let items: Vec<String> = collaborators.iter().map(|u| format!("\"{}\"", toml_escape(u))).collect();
format!("collaborators = [{}]", items.join(", "))
};
format!(
r##"[project]
name = "{name}"
description = "{description}"
default_branch = "{default_branch}"
{collaborators_line}
[tickets]
dir = "tickets"
archive_dir = "archive/tickets"
[worktrees]
dir = "worktrees"
agent_dirs = [".claude", ".cursor", ".windsurf"]
[agents]
max_concurrent = 3
max_workers_per_epic = 1
max_workers_on_default = 1
instructions = ".apm/agents/default/agents.md"
[workers]
agent = "claude"
[workers.options]
model = "sonnet"
[worker_profiles.spec_agent]
role = "spec-writer"
[logging]
enabled = false
file = "{log_file}"
"##
)
}
fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
let path = apm_dir.join("local.toml");
if !path.exists() {
let username_escaped = toml_escape(username);
std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
}
Ok(())
}
pub fn default_workflow_toml() -> &'static str {
include_str!("default/workflow.toml")
}
pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
#[derive(serde::Deserialize)]
struct Wrapper {
workflow: crate::config::WorkflowConfig,
}
let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
.expect("default workflow.toml is valid TOML");
let mut map = std::collections::HashMap::new();
for state in &w.workflow.states {
for tr in &state.transitions {
if matches!(
tr.completion,
crate::config::CompletionStrategy::Merge
| crate::config::CompletionStrategy::PrOrEpicMerge
) {
if let Some(ref of) = tr.on_failure {
map.insert(tr.to.clone(), of.clone());
}
}
}
}
map
}
fn default_ticket_toml() -> &'static str {
include_str!("default/ticket.toml")
}
fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
if crate::git_util::has_commits(root) {
return Ok(());
}
crate::git_util::stage_files(root, &[
".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
])?;
if crate::git_util::commit(root, "apm: initialize project").is_ok() {
messages.push("Created initial commit.".to_string());
}
Ok(())
}
fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
if let Ok(config) = crate::config::Config::load(root) {
let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
let wt_dir = main_root.join(&config.worktrees.dir);
if !wt_dir.exists() {
std::fs::create_dir_all(&wt_dir)?;
messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
}
}
Ok(())
}
pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
let mut messages: Vec<String> = Vec::new();
let apm_dir = root.join(".apm");
std::fs::create_dir_all(&apm_dir)?;
let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
if dockerfile_path.exists() {
messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
return Ok(SetupDockerOutput { messages });
}
std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
messages.push("Created .apm/Dockerfile.apm-worker".to_string());
messages.push(String::new());
messages.push("Next steps:".to_string());
messages.push(" 1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
messages.push(" 2. Build the image:".to_string());
messages.push(" docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
messages.push(" 3. Add to .apm/config.toml:".to_string());
messages.push(" [workers]".to_string());
messages.push(" container = \"apm-worker\"".to_string());
messages.push(" 4. Configure credential lookup (optional, macOS only):".to_string());
messages.push(" [workers.keychain]".to_string());
messages.push(" ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
Ok(SetupDockerOutput { messages })
}
const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
# System tools
RUN apt-get update && apt-get install -y \
curl git unzip ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Claude CLI
RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
# apm binary (replace with your version or a downloaded release)
COPY target/release/apm /usr/local/bin/apm
# Add project-specific dependencies here:
# RUN apt-get install -y nodejs npm # for Node projects
# RUN pip install -r requirements.txt # for Python projects
# gh CLI is NOT needed — the worker only runs local git commits;
# push and PR creation happen on the host via apm state <id> implemented.
WORKDIR /workspace
"#;
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn git_init(dir: &Path) {
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn detect_default_branch_fresh_repo() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
let branch = detect_default_branch(tmp.path());
assert_eq!(branch, "main");
}
#[test]
fn detect_default_branch_non_git() {
let tmp = TempDir::new().unwrap();
let branch = detect_default_branch(tmp.path());
assert_eq!(branch, "main");
}
#[test]
fn ensure_gitignore_creates_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
let mut msgs = Vec::new();
ensure_gitignore(&path, None, &mut msgs).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains(".apm/local.toml"));
assert!(contents.contains(".apm/*.init"));
assert!(contents.contains(".apm/sessions.json"));
assert!(contents.contains(".apm/credentials.json"));
}
#[test]
fn ensure_gitignore_appends_missing_entry() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
std::fs::write(&path, "node_modules\n").unwrap();
let mut msgs = Vec::new();
ensure_gitignore(&path, None, &mut msgs).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("node_modules"));
assert!(contents.contains(".apm/local.toml"));
}
#[test]
fn ensure_gitignore_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
let mut msgs = Vec::new();
ensure_gitignore(&path, None, &mut msgs).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
ensure_gitignore(&path, None, &mut msgs).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(before, after);
}
#[test]
fn setup_creates_expected_files() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
assert!(tmp.path().join("tickets").exists());
assert!(tmp.path().join(".apm/config.toml").exists());
assert!(tmp.path().join(".apm/workflow.toml").exists());
assert!(tmp.path().join(".apm/ticket.toml").exists());
assert!(tmp.path().join(".apm/agents/default/agents.md").exists());
assert!(tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
assert!(tmp.path().join(".apm/agents/default/apm.worker.md").exists());
assert!(!tmp.path().join(".apm/agents.md").exists());
assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
assert!(!tmp.path().join(".apm/apm.worker.md").exists());
assert!(tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
assert!(tmp.path().join(".apm/agents/claude/apm.worker.md").exists());
assert!(tmp.path().join(".gitignore").exists());
assert!(tmp.path().join("CLAUDE.md").exists());
}
#[test]
fn setup_non_tty_uses_dir_name_and_empty_description() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
assert!(config.contains(&format!("name = \"{dir_name}\"")));
assert!(config.contains("description = \"\""));
}
#[test]
fn setup_is_idempotent() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
let config_path = tmp.path().join(".apm/config.toml");
let original = std::fs::read_to_string(&config_path).unwrap();
setup(tmp.path(), None, None, None).unwrap();
let after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(original, after);
}
#[test]
fn migrate_moves_files_and_updates_claude_md() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
migrate(tmp.path()).unwrap();
assert!(tmp.path().join(".apm/config.toml").exists());
assert!(tmp.path().join(".apm/agents.md").exists());
assert!(!tmp.path().join("apm.toml").exists());
assert!(!tmp.path().join("apm.agents.md").exists());
let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
assert!(claude.contains("@.apm/agents.md"));
assert!(!claude.contains("@apm.agents.md"));
}
#[test]
fn migrate_already_migrated() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
migrate(tmp.path()).unwrap();
}
#[test]
fn setup_docker_creates_dockerfile() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup_docker(tmp.path()).unwrap();
let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
assert!(dockerfile.exists());
let contents = std::fs::read_to_string(&dockerfile).unwrap();
assert!(contents.contains("FROM rust:1.82-slim"));
assert!(contents.contains("claude"));
assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
}
#[test]
fn setup_docker_idempotent() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup_docker(tmp.path()).unwrap();
let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
setup_docker(tmp.path()).unwrap();
let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
assert_eq!(before, after);
}
#[test]
fn default_config_escapes_special_chars() {
let name = r#"my\"project"#;
let description = r#"desc with "quotes" and \backslash"#;
let branch = "main";
let config = default_config(name, description, branch, &[]);
toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
}
#[test]
fn write_local_toml_creates_file() {
let tmp = TempDir::new().unwrap();
write_local_toml(tmp.path(), "alice").unwrap();
let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
assert!(contents.contains("username = \"alice\""));
}
#[test]
fn write_local_toml_idempotent() {
let tmp = TempDir::new().unwrap();
write_local_toml(tmp.path(), "alice").unwrap();
let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
write_local_toml(tmp.path(), "bob").unwrap();
let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
assert_eq!(first, second);
assert!(second.contains("alice"));
}
#[test]
fn setup_non_tty_no_local_toml() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
assert!(!tmp.path().join(".apm/local.toml").exists());
}
#[test]
fn default_config_with_collaborators() {
let config = default_config("proj", "desc", "main", &["alice"]);
let parsed: toml::Value = toml::from_str(&config).unwrap();
let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
assert_eq!(collaborators.len(), 1);
assert_eq!(collaborators[0].as_str().unwrap(), "alice");
}
#[test]
fn default_config_empty_collaborators() {
let config = default_config("proj", "desc", "main", &[]);
let parsed: toml::Value = toml::from_str(&config).unwrap();
let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
assert!(collaborators.is_empty());
}
#[test]
fn write_default_creates_new_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.toml");
let mut msgs = Vec::new();
let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
assert!(matches!(action, WriteAction::Created));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
}
#[test]
fn write_default_unchanged_when_identical() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.toml");
std::fs::write(&path, "content").unwrap();
let mut msgs = Vec::new();
let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
assert!(matches!(action, WriteAction::Unchanged));
}
#[test]
fn write_default_non_tty_writes_init_when_differs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.toml");
std::fs::write(&path, "modified").unwrap();
let mut msgs = Vec::new();
let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
assert!(matches!(action, WriteAction::InitWritten));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
assert_eq!(
std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
"default"
);
}
#[test]
fn init_path_for_preserves_extension() {
let p = std::path::Path::new("/a/b/workflow.toml");
assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
let p = std::path::Path::new("/a/b/agents.md");
assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
}
#[test]
fn setup_writes_init_files_when_content_differs() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
let workflow = tmp.path().join(".apm/workflow.toml");
std::fs::write(&workflow, "# custom workflow\n").unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(tmp.path().join(".apm/workflow.toml.init").exists());
assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
assert_eq!(init_content, default_workflow_toml());
}
#[test]
fn setup_writes_config_init_when_modified() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
let config_path = tmp.path().join(".apm/config.toml");
let mut content = std::fs::read_to_string(&config_path).unwrap();
content.push_str("\n[custom]\nfoo = \"bar\"\n");
std::fs::write(&config_path, &content).unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(tmp.path().join(".apm/config.toml.init").exists());
assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
assert!(!init_content.contains("[custom]"));
assert!(init_content.contains("[project]"));
assert!(init_content.contains("[workers]"));
assert!(init_content.contains("collaborators = []"));
}
#[test]
fn setup_no_false_diff_when_collaborators_present() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, Some("alice")).unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(!tmp.path().join(".apm/config.toml.init").exists());
}
#[test]
fn setup_config_init_collaborators_match_live() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, Some("alice")).unwrap();
let config_path = tmp.path().join(".apm/config.toml");
let mut content = std::fs::read_to_string(&config_path).unwrap();
content.push_str("\n[custom]\nfoo = \"bar\"\n");
std::fs::write(&config_path, &content).unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(tmp.path().join(".apm/config.toml.init").exists());
let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
}
#[test]
fn setup_migrates_flat_agent_files_to_agents_default() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
std::fs::write(tmp.path().join(".apm/agents.md"), "# agents\n").unwrap();
std::fs::write(tmp.path().join(".apm/apm.spec-writer.md"), "# spec\n").unwrap();
std::fs::write(tmp.path().join(".apm/apm.worker.md"), "# worker\n").unwrap();
std::fs::write(tmp.path().join(".apm/style.md"), "# style\n").unwrap();
std::fs::write(
tmp.path().join("CLAUDE.md"),
"@.apm/agents.md\n@.apm/style.md\n",
)
.unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(!tmp.path().join(".apm/agents.md").exists());
assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
assert!(!tmp.path().join(".apm/apm.worker.md").exists());
assert!(!tmp.path().join(".apm/style.md").exists());
assert!(tmp.path().join(".apm/agents/default/agents.md").exists());
assert!(tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
assert!(tmp.path().join(".apm/agents/default/apm.worker.md").exists());
assert!(tmp.path().join(".apm/agents/default/style.md").exists());
let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
assert!(claude.contains("@.apm/agents/default/agents.md"));
assert!(claude.contains("@.apm/agents/default/style.md"));
assert!(!claude.contains("@.apm/agents.md"));
assert!(!claude.contains("@.apm/style.md"));
}
#[test]
fn setup_no_false_diff_empty_collaborators() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
setup(tmp.path(), None, None, None).unwrap();
assert!(!tmp.path().join(".apm/config.toml.init").exists());
}
#[test]
fn default_workflow_toml_is_valid() {
use crate::config::{SatisfiesDeps, WorkflowFile};
let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
let states = &parsed.workflow.states;
let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
assert_eq!(
ids,
["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
);
for id in ["groomed", "ammend"] {
let s = states.iter().find(|s| s.id == id).unwrap();
assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
}
for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
let s = states.iter().find(|s| s.id == id).unwrap();
assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
}
}
#[test]
fn default_workflow_all_transitions_have_valid_outcomes() {
use crate::config::{resolve_outcome, WorkflowFile};
let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
let states = &parsed.workflow.states;
let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
states.iter().map(|s| (s.id.as_str(), s)).collect();
let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"];
for state in states {
for t in &state.transitions {
let target = state_map
.get(t.to.as_str())
.unwrap_or_else(|| panic!("target state '{}' not found in map", t.to));
let outcome = resolve_outcome(t, target);
assert!(
!outcome.is_empty(),
"transition {} → {} has empty outcome",
state.id, t.to
);
assert!(
valid_outcomes.contains(&outcome),
"transition {} → {} has unexpected outcome '{outcome}'",
state.id, t.to
);
}
}
}
#[test]
fn default_ticket_toml_is_valid() {
use crate::config::TicketFile;
let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
let sections = &parsed.ticket.sections;
for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
let s = sections.iter().find(|s| s.name == name).unwrap();
assert!(s.required, "section '{name}' should be required");
}
}
#[test]
fn default_config_has_in_repo_worktrees_dir() {
let config = default_config("myproj", "desc", "main", &[]);
assert!(
config.contains("dir = \"worktrees\""),
"default config should use in-repo worktrees dir: {config}"
);
assert!(
!config.contains("--worktrees"),
"default config must not reference the old external layout: {config}"
);
}
#[test]
fn setup_gitignore_includes_worktrees_pattern() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(contents.contains("/worktrees/"), ".gitignore must contain /worktrees/");
assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
}
#[test]
fn ensure_gitignore_worktrees_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
let mut msgs = Vec::new();
ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
let count = before.matches("/worktrees/").count();
assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
}
#[test]
fn setup_creates_worktrees_dir_inside_repo() {
let tmp = TempDir::new().unwrap();
git_init(tmp.path());
setup(tmp.path(), None, None, None).unwrap();
assert!(
tmp.path().join("worktrees").exists(),
"worktrees dir should be created inside the repo"
);
}
#[test]
fn worktree_gitignore_pattern_simple() {
assert_eq!(
worktree_gitignore_pattern(std::path::Path::new("worktrees")),
Some("/worktrees/".to_string())
);
}
#[test]
fn worktree_gitignore_pattern_hidden_dir() {
assert_eq!(
worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
Some("/.apm--worktrees/".to_string())
);
}
#[test]
fn worktree_gitignore_pattern_nested() {
assert_eq!(
worktree_gitignore_pattern(std::path::Path::new("build/wt")),
Some("/build/wt/".to_string())
);
}
#[test]
fn worktree_gitignore_pattern_absolute_is_none() {
assert_eq!(
worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
None
);
}
#[test]
fn worktree_gitignore_pattern_parent_relative_is_none() {
assert_eq!(
worktree_gitignore_pattern(std::path::Path::new("../external")),
None
);
}
}