1use anyhow::Result;
2use std::path::Path;
3
4pub struct SetupOutput {
5 pub messages: Vec<String>,
6}
7
8pub struct SetupDockerOutput {
9 pub messages: Vec<String>,
10}
11
12fn write_default(path: &Path, content: &str, label: &str, messages: &mut Vec<String>) -> Result<()> {
15 if !path.exists() {
16 std::fs::write(path, content)?;
17 messages.push(format!("Created {label}"));
18 return Ok(());
19 }
20
21 let existing = std::fs::read_to_string(path)?;
22 if existing == content {
23 return Ok(());
24 }
25
26 let init_path = init_path_for(path);
28 std::fs::write(&init_path, content)?;
29 messages.push(format!("{label} differs from default — wrote {label}.init for comparison"));
30 Ok(())
31}
32
33fn init_path_for(path: &Path) -> std::path::PathBuf {
35 let mut name = path.file_name().unwrap_or_default().to_os_string();
36 name.push(".init");
37 path.with_file_name(name)
38}
39
40pub fn setup(root: &Path, name: Option<&str>, description: Option<&str>, username: Option<&str>, workers_default: Option<&str>) -> Result<SetupOutput> {
41 let mut messages: Vec<String> = Vec::new();
42
43 let tickets_dir = root.join("tickets");
44 if !tickets_dir.exists() {
45 std::fs::create_dir_all(&tickets_dir)?;
46 messages.push("Created tickets/".to_string());
47 }
48
49 let apm_dir = root.join(".apm");
50 std::fs::create_dir_all(&apm_dir)?;
51
52 let local_toml = apm_dir.join("local.toml");
53
54 let has_git_host = {
56 let config_path = apm_dir.join("config.toml");
57 config_path.exists() && crate::config::Config::load(root)
58 .map(|cfg| cfg.git_host.provider.is_some())
59 .unwrap_or(false)
60 };
61
62 if !has_git_host && !local_toml.exists() {
64 if let Some(u) = username {
65 if !u.is_empty() {
66 write_local_toml(&apm_dir, u)?;
67 messages.push("Created .apm/local.toml".to_string());
68 }
69 }
70 }
71
72 let effective_username = username.unwrap_or("");
73 let config_path = apm_dir.join("config.toml");
74 if !config_path.exists() {
75 let default_name = name.unwrap_or_else(|| {
76 root.file_name()
77 .and_then(|n| n.to_str())
78 .unwrap_or("project")
79 });
80 let effective_description = description.unwrap_or("");
81 let collaborators: Vec<&str> = if effective_username.is_empty() {
82 vec![]
83 } else {
84 vec![effective_username]
85 };
86 let branch = detect_default_branch(root);
87 let wdefault = workers_default.unwrap_or("claude/coder");
88 std::fs::write(&config_path, default_config(default_name, effective_description, &branch, &collaborators, wdefault))?;
89 messages.push("Created .apm/config.toml".to_string());
90 } else {
91 let existing = std::fs::read_to_string(&config_path)?;
94 if let Ok(val) = existing.parse::<toml::Value>() {
95 let n = val.get("project")
96 .and_then(|p| p.get("name"))
97 .and_then(|v| v.as_str())
98 .unwrap_or("project");
99 let d = val.get("project")
100 .and_then(|p| p.get("description"))
101 .and_then(|v| v.as_str())
102 .unwrap_or("");
103 let b = val.get("project")
104 .and_then(|p| p.get("default_branch"))
105 .and_then(|v| v.as_str())
106 .unwrap_or("main");
107 let collab_owned: Vec<String> = val
108 .get("project")
109 .and_then(|p| p.get("collaborators"))
110 .and_then(|v| v.as_array())
111 .map(|arr| {
112 arr.iter()
113 .filter_map(|v| v.as_str().map(|s| s.to_owned()))
114 .collect()
115 })
116 .unwrap_or_default();
117 let collabs: Vec<&str> = collab_owned.iter().map(|s| s.as_str()).collect();
118 let wdefault = workers_default.unwrap_or("claude/coder");
119 write_default(&config_path, &default_config(n, d, b, &collabs, wdefault), ".apm/config.toml", &mut messages)?;
120 }
121 }
122 write_default(&apm_dir.join("workflow.toml"), default_workflow_toml(), ".apm/workflow.toml", &mut messages)?;
123 write_default(&apm_dir.join("ticket.toml"), default_ticket_toml(), ".apm/ticket.toml", &mut messages)?;
124 migrate_flat_agent_files(root, &apm_dir, &mut messages)?;
125 migrate_project_md_location(root, &apm_dir, &mut messages)?;
126 migrate_agents_default_to_claude(root, &apm_dir, &mut messages)?;
127 let agents_claude_dir = apm_dir.join("agents/claude");
128 std::fs::create_dir_all(&agents_claude_dir)
129 .map_err(|e| anyhow::anyhow!("cannot create {}: {e}", agents_claude_dir.display()))?;
130 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)?;
131 write_default(&agents_claude_dir.join("apm.coder.md"), include_str!("default/agents/claude/apm.coder.md"), ".apm/agents/claude/apm.coder.md", &mut messages)?;
132 write_default(&agents_claude_dir.join("spec-writer.toml"), SPEC_WRITER_MANIFEST_STUB, ".apm/agents/claude/spec-writer.toml", &mut messages)?;
133 write_default(&agents_claude_dir.join("coder.toml"), CODER_MANIFEST_STUB, ".apm/agents/claude/coder.toml", &mut messages)?;
134 write_default(
135 &apm_dir.join("project.md"),
136 include_str!("default/project.md"),
137 ".apm/project.md",
138 &mut messages,
139 )?;
140 write_default(
141 &agents_claude_dir.join("apm.main-agent.md"),
142 include_str!("default/agents/claude/apm.main-agent.md"),
143 ".apm/agents/claude/apm.main-agent.md",
144 &mut messages,
145 )?;
146 ensure_claude_md(root, &[
147 ".apm/project.md",
148 ".apm/agents/claude/apm.main-agent.md",
149 ], &mut messages)?;
150 let gitignore = root.join(".gitignore");
151 let wt_pattern = crate::config::Config::load(root)
152 .ok()
153 .and_then(|c| worktree_gitignore_pattern(&c.worktrees.dir));
154 ensure_gitignore(&gitignore, wt_pattern.as_deref(), &mut messages)?;
155 maybe_initial_commit(root, &mut messages)?;
156 ensure_worktrees_dir(root, &mut messages)?;
157 Ok(SetupOutput { messages })
158}
159
160fn migrate_flat_agent_files(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
164 let moves = [
165 ("agents.md", "agents.md"),
166 ("apm.spec-writer.md", "apm.spec-writer.md"),
167 ("apm.worker.md", "apm.worker.md"),
168 ("style.md", "style.md"),
169 ];
170 let has_flat = moves.iter().any(|(name, _)| apm_dir.join(name).exists());
171 if has_flat {
172 let agents_default_dir = apm_dir.join("agents/default");
173 std::fs::create_dir_all(&agents_default_dir)?;
174 for (old_name, new_name) in &moves {
175 let old_path = apm_dir.join(old_name);
176 let new_path = agents_default_dir.join(new_name);
177 if old_path.exists() && !new_path.exists() {
178 std::fs::rename(&old_path, &new_path)?;
179 messages.push(format!("Moved .apm/{old_name} → .apm/agents/default/{new_name}"));
180 }
181 }
182 }
183
184 let path_rewrites: &[(&str, &str)] = &[
185 ("@.apm/agents.md", "@.apm/agents/default/agents.md"),
186 ("@.apm/style.md", "@.apm/agents/default/style.md"),
187 ("@.apm/agents/default/agents.md", "@.apm/project.md\n@.apm/agents/default/apm.main-agent.md"),
188 ];
189 let claude_path = root.join("CLAUDE.md");
190 if claude_path.exists() {
191 let contents = std::fs::read_to_string(&claude_path)?;
192 let mut updated = contents.clone();
193 for (old, new) in path_rewrites {
194 updated = updated.replace(old, new);
195 }
196 if updated != contents {
197 std::fs::write(&claude_path, &updated)?;
198 messages.push("Updated CLAUDE.md (agent file paths → agents/default/)".to_string());
199 }
200 }
201
202 let instructions_rewrites: &[(&str, &str)] = &[
203 (".apm/agents.md", ".apm/agents/default/agents.md"),
204 (".apm/apm.spec-writer.md", ".apm/agents/default/apm.spec-writer.md"),
205 (".apm/apm.worker.md", ".apm/agents/default/apm.worker.md"),
206 (".apm/style.md", ".apm/agents/default/style.md"),
207 ("instructions = \".apm/agents/default/agents.md\"", "project = \".apm/project.md\""),
208 ];
209
210 let config_path = apm_dir.join("config.toml");
211 if config_path.exists() {
212 let contents = std::fs::read_to_string(&config_path)?;
213 let mut updated = contents.clone();
214 for (old, new) in instructions_rewrites {
215 updated = updated.replace(old, new);
216 }
217 if updated != contents {
218 std::fs::write(&config_path, &updated)?;
219 messages.push("Updated .apm/config.toml (instructions paths → agents/default/)".to_string());
220 }
221 }
222
223 let workflow_path = apm_dir.join("workflow.toml");
224 if workflow_path.exists() {
225 let contents = std::fs::read_to_string(&workflow_path)?;
226 let mut updated = contents.clone();
227 for (old, new) in instructions_rewrites {
228 updated = updated.replace(old, new);
229 }
230 if updated != contents {
231 std::fs::write(&workflow_path, &updated)?;
232 messages.push("Updated .apm/workflow.toml (instructions paths → agents/default/)".to_string());
233 }
234 }
235
236 Ok(())
237}
238
239fn migrate_project_md_location(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
240 let old_path = apm_dir.join("agents/default/apm.project.md");
241 let new_path = apm_dir.join("project.md");
242 if old_path.exists() && !new_path.exists() {
243 std::fs::rename(&old_path, &new_path)?;
244 messages.push("Moved .apm/agents/default/apm.project.md → .apm/project.md".to_string());
245 }
246 let claude_path = root.join("CLAUDE.md");
247 if claude_path.exists() {
248 let contents = std::fs::read_to_string(&claude_path)?;
249 let updated = contents.replace("@.apm/agents/default/apm.project.md", "@.apm/project.md");
250 if updated != contents {
251 std::fs::write(&claude_path, &updated)?;
252 messages.push("Updated CLAUDE.md (project → .apm/project.md)".to_string());
253 }
254 }
255 let config_path = apm_dir.join("config.toml");
256 if config_path.exists() {
257 let contents = std::fs::read_to_string(&config_path)?;
258 let updated = contents.replace(
259 "project = \".apm/agents/default/apm.project.md\"",
260 "project = \".apm/project.md\"",
261 );
262 if updated != contents {
263 std::fs::write(&config_path, &updated)?;
264 messages.push("Updated config.toml (project → .apm/project.md)".to_string());
265 }
266 }
267 Ok(())
268}
269
270fn migrate_agents_default_to_claude(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
273 let default_dir = apm_dir.join("agents/default");
274 let claude_dir = apm_dir.join("agents/claude");
275
276 if !default_dir.exists() || claude_dir.exists() {
277 return Ok(());
278 }
279
280 std::fs::create_dir_all(&claude_dir)?;
281
282 let files = ["apm.spec-writer.md", "apm.coder.md", "apm.main-agent.md", "style.md", "agents.md"];
283 for file in &files {
284 let old = default_dir.join(file);
285 let new = claude_dir.join(file);
286 if old.exists() && !new.exists() {
287 std::fs::rename(&old, &new)?;
288 messages.push(format!("Moved .apm/agents/default/{file} → .apm/agents/claude/{file}"));
289 }
290 }
291
292 if let Ok(entries) = std::fs::read_dir(&default_dir) {
293 let remaining: Vec<_> = entries.filter_map(|e| e.ok()).collect();
294 if remaining.is_empty() {
295 let _ = std::fs::remove_dir(&default_dir);
296 }
297 }
298
299 let rewrites: &[(&str, &str)] = &[
300 ("@.apm/agents/default/apm.main-agent.md", "@.apm/agents/claude/apm.main-agent.md"),
301 ("@.apm/agents/default/apm.coder.md", "@.apm/agents/claude/apm.coder.md"),
302 ("@.apm/agents/default/apm.spec-writer.md", "@.apm/agents/claude/apm.spec-writer.md"),
303 ("@.apm/agents/default/style.md", "@.apm/agents/claude/style.md"),
304 ("@.apm/agents/default/agents.md", "@.apm/agents/claude/agents.md"),
305 ];
306
307 let claude_md = root.join("CLAUDE.md");
308 if claude_md.exists() {
309 let contents = std::fs::read_to_string(&claude_md)?;
310 let mut updated = contents.clone();
311 for (old, new) in rewrites {
312 updated = updated.replace(old, new);
313 }
314 if updated != contents {
315 std::fs::write(&claude_md, &updated)?;
316 messages.push("Updated CLAUDE.md (agents/default/ → agents/claude/)".to_string());
317 }
318 }
319
320 Ok(())
321}
322
323pub fn migrate(root: &Path) -> Result<Vec<String>> {
324 let mut messages: Vec<String> = Vec::new();
325 let apm_dir = root.join(".apm");
326 let new_config = apm_dir.join("config.toml");
327
328 if new_config.exists() {
329 messages.push("Already migrated.".to_string());
330 return Ok(messages);
331 }
332
333 let old_config = root.join("apm.toml");
334 let old_agents = root.join("apm.agents.md");
335
336 if !old_config.exists() && !old_agents.exists() {
337 messages.push("Nothing to migrate.".to_string());
338 return Ok(messages);
339 }
340
341 std::fs::create_dir_all(&apm_dir)?;
342
343 if old_config.exists() {
344 std::fs::rename(&old_config, &new_config)?;
345 messages.push("Moved apm.toml → .apm/config.toml".to_string());
346 }
347
348 if old_agents.exists() {
349 let new_agents = apm_dir.join("agents.md");
350 std::fs::rename(&old_agents, &new_agents)?;
351 messages.push("Moved apm.agents.md → .apm/agents.md".to_string());
352 }
353
354 let claude_path = root.join("CLAUDE.md");
355 if claude_path.exists() {
356 let contents = std::fs::read_to_string(&claude_path)?;
357 if contents.contains("@apm.agents.md") {
358 let updated = contents.replace("@apm.agents.md", "@.apm/agents.md");
359 std::fs::write(&claude_path, updated)?;
360 messages.push("Updated CLAUDE.md (@apm.agents.md → @.apm/agents.md)".to_string());
361 }
362 }
363
364 Ok(messages)
365}
366
367pub fn detect_default_branch(root: &Path) -> String {
368 crate::git_util::current_branch(root)
369 .ok()
370 .filter(|s| !s.is_empty())
371 .unwrap_or_else(|| "main".to_string())
372}
373
374pub fn worktree_gitignore_pattern(dir: &Path) -> Option<String> {
379 let s = dir.to_string_lossy();
380 if s.starts_with('/') || s.starts_with("..") {
381 return None;
382 }
383 Some(format!("/{s}/"))
384}
385
386pub fn ensure_gitignore(path: &Path, worktree_pattern: Option<&str>, messages: &mut Vec<String>) -> Result<()> {
387 let static_entries = [".apm/local.toml", ".apm/*.init", ".apm/sessions.json", ".apm/credentials.json"];
388 let mut entries: Vec<&str> = static_entries.to_vec();
389 let owned_pattern;
390 if let Some(p) = worktree_pattern {
391 entries.push("# apm worktrees");
392 owned_pattern = p.to_owned();
393 entries.push(&owned_pattern);
394 }
395 if path.exists() {
396 let mut contents = std::fs::read_to_string(path)?;
397 let mut changed = false;
398 for entry in &entries {
399 if !contents.contains(entry) {
400 if !contents.ends_with('\n') {
401 contents.push('\n');
402 }
403 contents.push_str(entry);
404 contents.push('\n');
405 changed = true;
406 }
407 }
408 if changed {
409 std::fs::write(path, &contents)?;
410 messages.push("Updated .gitignore".to_string());
411 }
412 } else {
413 std::fs::write(path, entries.join("\n") + "\n")?;
414 messages.push("Created .gitignore".to_string());
415 }
416 Ok(())
417}
418
419fn ensure_claude_md(root: &Path, agents_paths: &[&str], messages: &mut Vec<String>) -> Result<()> {
420 let claude_path = root.join("CLAUDE.md");
421 if claude_path.exists() {
422 let contents = std::fs::read_to_string(&claude_path)?;
423 let absent: Vec<&str> = agents_paths
424 .iter()
425 .filter(|p| !contents.contains(&format!("@{p}")))
426 .copied()
427 .collect();
428 if absent.is_empty() {
429 return Ok(());
430 }
431 let prefix: String = absent.iter().map(|p| format!("@{p}\n")).collect();
432 let separator = if contents.is_empty() { "" } else { "\n" };
433 std::fs::write(&claude_path, format!("{prefix}{separator}{contents}"))?;
434 messages.push(format!(
435 "Updated CLAUDE.md (added {} import).",
436 absent.iter().map(|p| format!("@{p}")).collect::<Vec<_>>().join(", ")
437 ));
438 } else {
439 let content: String = agents_paths.iter().map(|p| format!("@{p}\n")).collect();
440 std::fs::write(&claude_path, content)?;
441 messages.push("Created CLAUDE.md.".to_string());
442 }
443 Ok(())
444}
445
446#[cfg(target_os = "macos")]
447fn default_log_file(name: &str) -> String {
448 format!("~/Library/Logs/apm/{name}.log")
449}
450
451#[cfg(not(target_os = "macos"))]
452fn default_log_file(name: &str) -> String {
453 format!("~/.local/state/apm/{name}.log")
454}
455
456fn toml_escape(s: &str) -> String {
457 s.replace('\\', "\\\\").replace('"', "\\\"")
458}
459
460fn default_config(name: &str, description: &str, default_branch: &str, collaborators: &[&str], workers_default: &str) -> String {
461 let log_file = default_log_file(name);
462 let name = toml_escape(name);
463 let description = toml_escape(description);
464 let default_branch = toml_escape(default_branch);
465 let log_file = toml_escape(&log_file);
466 let workers_default = toml_escape(workers_default);
467 let collaborators_line = {
468 let items: Vec<String> = collaborators.iter().map(|u| format!("\"{}\"", toml_escape(u))).collect();
469 format!("collaborators = [{}]", items.join(", "))
470 };
471 format!(
472 r##"[project]
473name = "{name}"
474description = "{description}"
475default_branch = "{default_branch}"
476{collaborators_line}
477
478[tickets]
479dir = "tickets"
480archive_dir = "archive/tickets"
481
482[worktrees]
483dir = ".apm--worktrees"
484agent_dirs = [".claude", ".cursor", ".windsurf"]
485
486[agents]
487max_concurrent = 3
488max_workers_per_epic = 1
489max_workers_on_default = 1
490project = ".apm/project.md"
491# side_tickets = true # allow workers to file side-note tickets
492# skip_permissions = false # skip Claude Code permission prompts in workers
493
494[workers]
495default = "{workers_default}"
496# model = "sonnet" # default model for all workers; set per-agent in .apm/agents/<agent>/<role>.toml instead
497# container = "apm-worker" # Docker image for worker agents; omit for local execution
498# env = {{}} # environment variables injected into every worker
499# keychain = {{}} # macOS Keychain items resolved at worker launch (secret_name = keychain_item)
500
501[logging]
502enabled = false
503file = "{log_file}"
504
505# [sync]
506# aggressive = true # fetch all remote branches before checking state
507
508# [git_host]
509# provider = "github" # git host provider; only "github" is supported
510# repo = "owner/repo" # repository path for PR creation and collaborator lookup
511# token_env = "GITHUB_TOKEN" # env var holding the API token
512
513# [server]
514# origin = "http://localhost:3000" # public-facing URL used in PR descriptions
515# url = "http://127.0.0.1:3000" # internal URL the CLI uses to reach apm-server
516
517# [context]
518# epic_sibling_cap = 20 # max sibling tickets included in worker context bundles
519# epic_byte_cap = 8192 # max byte size of the context bundle injected into worker prompts
520
521# [isolation]
522# read_allow = ["/etc/resolv.conf", "~/.gitconfig"] # paths workers may read outside the worktree
523# enforce_worktree_isolation = false # block writes outside APM_TICKET_WORKTREE
524
525# [work]
526# epic = "" # default epic ID assigned when creating new tickets with `apm new`
527"##
528 )
529}
530
531fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
532 let path = apm_dir.join("local.toml");
533 if !path.exists() {
534 let username_escaped = toml_escape(username);
535 std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
536 }
537 Ok(())
538}
539
540pub fn default_workflow_toml() -> &'static str {
541 include_str!("default/workflow.toml")
542}
543
544pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
547 #[derive(serde::Deserialize)]
548 struct Wrapper {
549 workflow: crate::config::WorkflowConfig,
550 }
551 let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
552 .expect("default workflow.toml is valid TOML");
553 let mut map = std::collections::HashMap::new();
554 for state in &w.workflow.states {
555 for tr in &state.transitions {
556 if matches!(
557 tr.completion,
558 crate::config::CompletionStrategy::Merge
559 | crate::config::CompletionStrategy::PrOrEpicMerge
560 ) {
561 if let Some(ref of) = tr.on_failure {
562 map.insert(tr.to.clone(), of.clone());
563 }
564 }
565 }
566 }
567 map
568}
569
570fn default_ticket_toml() -> &'static str {
571 include_str!("default/ticket.toml")
572}
573
574fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
575 if crate::git_util::has_commits(root) {
576 return Ok(());
577 }
578
579 crate::git_util::stage_files(root, &[
580 ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
581 ])?;
582
583 if crate::git_util::commit(root, "apm: initialize project").is_ok() {
584 messages.push("Created initial commit.".to_string());
585 }
586 Ok(())
587}
588
589fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
590 if let Ok(config) = crate::config::Config::load(root) {
591 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
592 let wt_dir = main_root.join(&config.worktrees.dir);
593 if !wt_dir.exists() {
594 std::fs::create_dir_all(&wt_dir)?;
595 messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
596 }
597 }
598 Ok(())
599}
600
601const SPEC_WRITER_MANIFEST_STUB: &str = "\
602# model = \"sonnet\" # model for the spec-writer profile; overrides [workers].model
603#
604# [env]
605# MY_VAR = \"value\" # environment variables injected into spec-writer workers
606";
607
608const CODER_MANIFEST_STUB: &str = "\
609# model = \"sonnet\" # model for the coder profile; overrides [workers].model
610#
611# [env]
612# MY_VAR = \"value\" # environment variables injected into coder workers
613";
614
615pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
616 let mut messages: Vec<String> = Vec::new();
617 let apm_dir = root.join(".apm");
618 std::fs::create_dir_all(&apm_dir)?;
619 let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
620 if dockerfile_path.exists() {
621 messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
622 return Ok(SetupDockerOutput { messages });
623 }
624 std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
625 messages.push("Created .apm/Dockerfile.apm-worker".to_string());
626 messages.push(String::new());
627 messages.push("Next steps:".to_string());
628 messages.push(" 1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
629 messages.push(" 2. Build the image:".to_string());
630 messages.push(" docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
631 messages.push(" 3. Add to .apm/config.toml:".to_string());
632 messages.push(" [workers]".to_string());
633 messages.push(" container = \"apm-worker\"".to_string());
634 messages.push(" 4. Configure credential lookup (optional, macOS only):".to_string());
635 messages.push(" [workers.keychain]".to_string());
636 messages.push(" ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
637 Ok(SetupDockerOutput { messages })
638}
639
640const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
641
642# System tools
643RUN apt-get update && apt-get install -y \
644 curl git unzip ca-certificates && \
645 rm -rf /var/lib/apt/lists/*
646
647# Claude CLI
648RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
649
650# apm binary (replace with your version or a downloaded release)
651COPY target/release/apm /usr/local/bin/apm
652
653# Add project-specific dependencies here:
654# RUN apt-get install -y nodejs npm # for Node projects
655# RUN pip install -r requirements.txt # for Python projects
656
657# gh CLI is NOT needed — the worker only runs local git commits;
658# push and PR creation happen on the host via apm state <id> implemented.
659
660WORKDIR /workspace
661"#;
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use std::process::Command;
667 use tempfile::TempDir;
668
669 fn git_init(dir: &Path) {
670 Command::new("git")
671 .args(["init", "-b", "main"])
672 .current_dir(dir)
673 .output()
674 .unwrap();
675 Command::new("git")
676 .args(["config", "user.email", "test@test.com"])
677 .current_dir(dir)
678 .output()
679 .unwrap();
680 Command::new("git")
681 .args(["config", "user.name", "Test"])
682 .current_dir(dir)
683 .output()
684 .unwrap();
685 }
686
687 #[test]
688 fn detect_default_branch_fresh_repo() {
689 let tmp = TempDir::new().unwrap();
690 git_init(tmp.path());
691 let branch = detect_default_branch(tmp.path());
692 assert_eq!(branch, "main");
693 }
694
695 #[test]
696 fn detect_default_branch_non_git() {
697 let tmp = TempDir::new().unwrap();
698 let branch = detect_default_branch(tmp.path());
699 assert_eq!(branch, "main");
700 }
701
702 #[test]
703 fn ensure_gitignore_creates_file() {
704 let tmp = TempDir::new().unwrap();
705 let path = tmp.path().join(".gitignore");
706 let mut msgs = Vec::new();
707 ensure_gitignore(&path, None, &mut msgs).unwrap();
708 let contents = std::fs::read_to_string(&path).unwrap();
709 assert!(contents.contains(".apm/local.toml"));
710 assert!(contents.contains(".apm/*.init"));
711 assert!(contents.contains(".apm/sessions.json"));
712 assert!(contents.contains(".apm/credentials.json"));
713 }
714
715 #[test]
716 fn ensure_gitignore_appends_missing_entry() {
717 let tmp = TempDir::new().unwrap();
718 let path = tmp.path().join(".gitignore");
719 std::fs::write(&path, "node_modules\n").unwrap();
720 let mut msgs = Vec::new();
721 ensure_gitignore(&path, None, &mut msgs).unwrap();
722 let contents = std::fs::read_to_string(&path).unwrap();
723 assert!(contents.contains("node_modules"));
724 assert!(contents.contains(".apm/local.toml"));
725 }
726
727 #[test]
728 fn ensure_gitignore_idempotent() {
729 let tmp = TempDir::new().unwrap();
730 let path = tmp.path().join(".gitignore");
731 let mut msgs = Vec::new();
732 ensure_gitignore(&path, None, &mut msgs).unwrap();
733 let before = std::fs::read_to_string(&path).unwrap();
734 ensure_gitignore(&path, None, &mut msgs).unwrap();
735 let after = std::fs::read_to_string(&path).unwrap();
736 assert_eq!(before, after);
737 }
738
739 #[test]
740 fn setup_creates_expected_files() {
741 let tmp = TempDir::new().unwrap();
742 git_init(tmp.path());
743 setup(tmp.path(), None, None, None, None).unwrap();
744
745 assert!(tmp.path().join("tickets").exists());
746 assert!(tmp.path().join(".apm/config.toml").exists());
747 assert!(tmp.path().join(".apm/workflow.toml").exists());
748 assert!(tmp.path().join(".apm/ticket.toml").exists());
749 assert!(!tmp.path().join(".apm/agents/claude/agents.md").exists());
750 assert!(tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
751 assert!(tmp.path().join(".apm/agents/claude/apm.coder.md").exists());
752 assert!(tmp.path().join(".apm/agents/claude/spec-writer.toml").exists());
753 assert!(tmp.path().join(".apm/agents/claude/coder.toml").exists());
754 assert!(tmp.path().join(".apm/project.md").exists());
755 assert!(tmp.path().join(".apm/agents/claude/apm.main-agent.md").exists());
756 assert!(!tmp.path().join(".apm/agents.md").exists());
757 assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
758 assert!(!tmp.path().join(".apm/apm.worker.md").exists());
759 assert!(!tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
760 assert!(!tmp.path().join(".apm/agents/default/apm.worker.md").exists());
761 assert!(tmp.path().join(".gitignore").exists());
762 assert!(tmp.path().join("CLAUDE.md").exists());
763 }
764
765 #[test]
766 fn setup_non_tty_uses_dir_name_and_empty_description() {
767 let tmp = TempDir::new().unwrap();
768 git_init(tmp.path());
769 setup(tmp.path(), None, None, None, None).unwrap();
770
771 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
772 let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
773 assert!(config.contains(&format!("name = \"{dir_name}\"")));
774 assert!(config.contains("description = \"\""));
775 }
776
777 #[test]
778 fn setup_is_idempotent() {
779 let tmp = TempDir::new().unwrap();
780 git_init(tmp.path());
781 setup(tmp.path(), None, None, None, None).unwrap();
782
783 let config_path = tmp.path().join(".apm/config.toml");
785 let original = std::fs::read_to_string(&config_path).unwrap();
786
787 setup(tmp.path(), None, None, None, None).unwrap();
788 let after = std::fs::read_to_string(&config_path).unwrap();
789 assert_eq!(original, after);
790 }
791
792 #[test]
793 fn migrate_moves_files_and_updates_claude_md() {
794 let tmp = TempDir::new().unwrap();
795 git_init(tmp.path());
796
797 std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
798 std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
799 std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
800
801 migrate(tmp.path()).unwrap();
802
803 assert!(tmp.path().join(".apm/config.toml").exists());
804 assert!(tmp.path().join(".apm/agents.md").exists());
805 assert!(!tmp.path().join("apm.toml").exists());
806 assert!(!tmp.path().join("apm.agents.md").exists());
807
808 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
809 assert!(claude.contains("@.apm/agents.md"));
810 assert!(!claude.contains("@apm.agents.md"));
811 }
812
813 #[test]
814 fn migrate_already_migrated() {
815 let tmp = TempDir::new().unwrap();
816 git_init(tmp.path());
817 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
818 std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
819
820 migrate(tmp.path()).unwrap();
822 }
823
824 #[test]
825 fn setup_docker_creates_dockerfile() {
826 let tmp = TempDir::new().unwrap();
827 git_init(tmp.path());
828 setup_docker(tmp.path()).unwrap();
829 let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
830 assert!(dockerfile.exists());
831 let contents = std::fs::read_to_string(&dockerfile).unwrap();
832 assert!(contents.contains("FROM rust:1.82-slim"));
833 assert!(contents.contains("claude"));
834 assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
835 }
836
837 #[test]
838 fn setup_docker_idempotent() {
839 let tmp = TempDir::new().unwrap();
840 git_init(tmp.path());
841 setup_docker(tmp.path()).unwrap();
842 let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
843 setup_docker(tmp.path()).unwrap();
845 let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
846 assert_eq!(before, after);
847 }
848
849 #[test]
850 fn default_config_escapes_special_chars() {
851 let name = r#"my\"project"#;
852 let description = r#"desc with "quotes" and \backslash"#;
853 let branch = "main";
854 let config = default_config(name, description, branch, &[], "claude/coder");
855 toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
856 }
857
858 #[test]
859 fn write_local_toml_creates_file() {
860 let tmp = TempDir::new().unwrap();
861 write_local_toml(tmp.path(), "alice").unwrap();
862 let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
863 assert!(contents.contains("username = \"alice\""));
864 }
865
866 #[test]
867 fn write_local_toml_idempotent() {
868 let tmp = TempDir::new().unwrap();
869 write_local_toml(tmp.path(), "alice").unwrap();
870 let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
871 write_local_toml(tmp.path(), "bob").unwrap();
872 let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
873 assert_eq!(first, second);
874 assert!(second.contains("alice"));
875 }
876
877 #[test]
878 fn setup_non_tty_no_local_toml() {
879 let tmp = TempDir::new().unwrap();
880 git_init(tmp.path());
881 setup(tmp.path(), None, None, None, None).unwrap();
882 assert!(!tmp.path().join(".apm/local.toml").exists());
883 }
884
885 #[test]
886 fn default_config_with_collaborators() {
887 let config = default_config("proj", "desc", "main", &["alice"], "claude/coder");
888 let parsed: toml::Value = toml::from_str(&config).unwrap();
889 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
890 assert_eq!(collaborators.len(), 1);
891 assert_eq!(collaborators[0].as_str().unwrap(), "alice");
892 }
893
894 #[test]
895 fn default_config_empty_collaborators() {
896 let config = default_config("proj", "desc", "main", &[], "claude/coder");
897 let parsed: toml::Value = toml::from_str(&config).unwrap();
898 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
899 assert!(collaborators.is_empty());
900 }
901
902 #[test]
903 fn write_default_creates_new_file() {
904 let tmp = TempDir::new().unwrap();
905 let path = tmp.path().join("test.toml");
906 let mut msgs = Vec::new();
907 write_default(&path, "content", "test.toml", &mut msgs).unwrap();
908 assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
909 assert!(msgs.iter().any(|m| m.contains("Created")));
910 }
911
912 #[test]
913 fn write_default_unchanged_when_identical() {
914 let tmp = TempDir::new().unwrap();
915 let path = tmp.path().join("test.toml");
916 std::fs::write(&path, "content").unwrap();
917 let mut msgs = Vec::new();
918 write_default(&path, "content", "test.toml", &mut msgs).unwrap();
919 assert!(msgs.is_empty());
920 }
921
922 #[test]
923 fn write_default_non_tty_writes_init_when_differs() {
924 let tmp = TempDir::new().unwrap();
925 let path = tmp.path().join("test.toml");
926 std::fs::write(&path, "modified").unwrap();
927 let mut msgs = Vec::new();
928 write_default(&path, "default", "test.toml", &mut msgs).unwrap();
929 assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
930 assert_eq!(
931 std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
932 "default"
933 );
934 }
935
936 #[test]
937 fn init_path_for_preserves_extension() {
938 let p = std::path::Path::new("/a/b/workflow.toml");
939 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
940
941 let p = std::path::Path::new("/a/b/agents.md");
942 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
943 }
944
945 #[test]
946 fn setup_writes_init_files_when_content_differs() {
947 let tmp = TempDir::new().unwrap();
948 git_init(tmp.path());
949 setup(tmp.path(), None, None, None, None).unwrap();
951
952 let workflow = tmp.path().join(".apm/workflow.toml");
954 std::fs::write(&workflow, "# custom workflow\n").unwrap();
955
956 setup(tmp.path(), None, None, None, None).unwrap();
958 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
959 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
961 let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
963 assert_eq!(init_content, default_workflow_toml());
964 }
965
966 #[test]
967 fn setup_writes_config_init_when_modified() {
968 let tmp = TempDir::new().unwrap();
969 git_init(tmp.path());
970 setup(tmp.path(), None, None, None, None).unwrap();
971
972 let config_path = tmp.path().join(".apm/config.toml");
974 let mut content = std::fs::read_to_string(&config_path).unwrap();
975 content.push_str("\n[custom]\nfoo = \"bar\"\n");
976 std::fs::write(&config_path, &content).unwrap();
977
978 setup(tmp.path(), None, None, None, None).unwrap();
980 assert!(tmp.path().join(".apm/config.toml.init").exists());
981 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
983 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
985 assert!(!init_content.contains("[custom]"));
986 assert!(init_content.contains("[project]"));
987 assert!(init_content.contains("[workers]"));
988 assert!(init_content.contains("collaborators = []"));
990 }
991
992 #[test]
993 fn setup_no_false_diff_when_collaborators_present() {
994 let tmp = TempDir::new().unwrap();
995 git_init(tmp.path());
996 setup(tmp.path(), None, None, Some("alice"), None).unwrap();
998
999 setup(tmp.path(), None, None, None, None).unwrap();
1001
1002 assert!(!tmp.path().join(".apm/config.toml.init").exists());
1004 }
1005
1006 #[test]
1007 fn setup_config_init_collaborators_match_live() {
1008 let tmp = TempDir::new().unwrap();
1009 git_init(tmp.path());
1010 setup(tmp.path(), None, None, Some("alice"), None).unwrap();
1011
1012 let config_path = tmp.path().join(".apm/config.toml");
1014 let mut content = std::fs::read_to_string(&config_path).unwrap();
1015 content.push_str("\n[custom]\nfoo = \"bar\"\n");
1016 std::fs::write(&config_path, &content).unwrap();
1017
1018 setup(tmp.path(), None, None, None, None).unwrap();
1019
1020 assert!(tmp.path().join(".apm/config.toml.init").exists());
1022 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
1024 assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
1025 }
1026
1027 #[test]
1028 fn setup_migrates_flat_agent_files_to_agents_default() {
1029 let tmp = TempDir::new().unwrap();
1030 git_init(tmp.path());
1031
1032 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1034 std::fs::write(tmp.path().join(".apm/agents.md"), "# agents\n").unwrap();
1035 std::fs::write(tmp.path().join(".apm/apm.spec-writer.md"), "# spec\n").unwrap();
1036 std::fs::write(tmp.path().join(".apm/apm.worker.md"), "# worker\n").unwrap();
1037 std::fs::write(tmp.path().join(".apm/style.md"), "# style\n").unwrap();
1038 std::fs::write(
1039 tmp.path().join("CLAUDE.md"),
1040 "@.apm/agents.md\n@.apm/style.md\n",
1041 )
1042 .unwrap();
1043
1044 setup(tmp.path(), None, None, None, None).unwrap();
1045
1046 assert!(!tmp.path().join(".apm/agents.md").exists());
1048 assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
1049 assert!(!tmp.path().join(".apm/apm.worker.md").exists());
1050 assert!(!tmp.path().join(".apm/style.md").exists());
1051
1052 assert!(tmp.path().join(".apm/agents/claude/agents.md").exists());
1054 assert!(tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
1055 assert!(tmp.path().join(".apm/agents/claude/apm.coder.md").exists());
1056 assert!(tmp.path().join(".apm/agents/claude/style.md").exists());
1057
1058 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1060 assert!(claude.contains("@.apm/project.md"));
1061 assert!(claude.contains("@.apm/agents/claude/apm.main-agent.md"));
1062 assert!(!claude.contains("@.apm/agents/claude/agents.md"));
1063 assert!(claude.contains("@.apm/agents/claude/style.md"));
1064 assert!(!claude.contains("@.apm/agents.md"));
1065 assert!(!claude.contains("@.apm/style.md"));
1066 }
1067
1068 #[test]
1069 fn setup_no_false_diff_empty_collaborators() {
1070 let tmp = TempDir::new().unwrap();
1071 git_init(tmp.path());
1072 setup(tmp.path(), None, None, None, None).unwrap();
1074 setup(tmp.path(), None, None, None, None).unwrap();
1076 assert!(!tmp.path().join(".apm/config.toml.init").exists());
1077 }
1078
1079 #[test]
1080 fn default_workflow_toml_is_valid() {
1081 use crate::config::{SatisfiesDeps, WorkflowFile};
1082
1083 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1084 let states = &parsed.workflow.states;
1085
1086 let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
1087 assert_eq!(
1088 ids,
1089 ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
1090 );
1091
1092 for id in ["groomed", "ammend"] {
1093 let s = states.iter().find(|s| s.id == id).unwrap();
1094 assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
1095 }
1096
1097 for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
1098 let s = states.iter().find(|s| s.id == id).unwrap();
1099 assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
1100 }
1101 }
1102
1103 #[test]
1104 fn default_workflow_all_transitions_have_valid_outcomes() {
1105 use crate::config::{resolve_outcome, WorkflowFile};
1106
1107 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1108 let states = &parsed.workflow.states;
1109 let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
1110 states.iter().map(|s| (s.id.as_str(), s)).collect();
1111
1112 let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"];
1113
1114 for state in states {
1115 for t in &state.transitions {
1116 let target = state_map
1117 .get(t.to.as_str())
1118 .unwrap_or_else(|| panic!("target state '{}' not found in map", t.to));
1119 let outcome = resolve_outcome(t, target);
1120 assert!(
1121 !outcome.is_empty(),
1122 "transition {} → {} has empty outcome",
1123 state.id, t.to
1124 );
1125 assert!(
1126 valid_outcomes.contains(&outcome),
1127 "transition {} → {} has unexpected outcome '{outcome}'",
1128 state.id, t.to
1129 );
1130 }
1131 }
1132 }
1133
1134 #[test]
1135 fn default_ticket_toml_is_valid() {
1136 use crate::config::TicketFile;
1137
1138 let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
1139 let sections = &parsed.ticket.sections;
1140
1141 for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
1142 let s = sections.iter().find(|s| s.name == name).unwrap();
1143 assert!(s.required, "section '{name}' should be required");
1144 }
1145 }
1146
1147 #[test]
1148 fn default_config_has_in_repo_worktrees_dir() {
1149 let config = default_config("myproj", "desc", "main", &[], "claude/coder");
1150 assert!(
1151 config.contains("dir = \".apm--worktrees\""),
1152 "default config should use .apm--worktrees dir: {config}"
1153 );
1154 }
1155
1156 #[test]
1157 fn setup_gitignore_includes_worktrees_pattern() {
1158 let tmp = TempDir::new().unwrap();
1159 git_init(tmp.path());
1160 setup(tmp.path(), None, None, None, None).unwrap();
1161 let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
1162 assert!(contents.contains("/.apm--worktrees/"), ".gitignore must contain /.apm--worktrees/");
1163 assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
1164 }
1165
1166 #[test]
1167 fn ensure_gitignore_worktrees_idempotent() {
1168 let tmp = TempDir::new().unwrap();
1169 let path = tmp.path().join(".gitignore");
1170 let mut msgs = Vec::new();
1171 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1172 let before = std::fs::read_to_string(&path).unwrap();
1173 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1174 let after = std::fs::read_to_string(&path).unwrap();
1175 assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
1176 let count = before.matches("/worktrees/").count();
1177 assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
1178 }
1179
1180 #[test]
1181 fn setup_creates_worktrees_dir_inside_repo() {
1182 let tmp = TempDir::new().unwrap();
1183 git_init(tmp.path());
1184 setup(tmp.path(), None, None, None, None).unwrap();
1185 assert!(
1186 tmp.path().join(".apm--worktrees").exists(),
1187 "worktrees dir should be created inside the repo"
1188 );
1189 }
1190
1191 #[test]
1192 fn worktree_gitignore_pattern_simple() {
1193 assert_eq!(
1194 worktree_gitignore_pattern(std::path::Path::new("worktrees")),
1195 Some("/worktrees/".to_string())
1196 );
1197 }
1198
1199 #[test]
1200 fn worktree_gitignore_pattern_hidden_dir() {
1201 assert_eq!(
1202 worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
1203 Some("/.apm--worktrees/".to_string())
1204 );
1205 }
1206
1207 #[test]
1208 fn worktree_gitignore_pattern_nested() {
1209 assert_eq!(
1210 worktree_gitignore_pattern(std::path::Path::new("build/wt")),
1211 Some("/build/wt/".to_string())
1212 );
1213 }
1214
1215 #[test]
1216 fn worktree_gitignore_pattern_absolute_is_none() {
1217 assert_eq!(
1218 worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
1219 None
1220 );
1221 }
1222
1223 #[test]
1224 fn worktree_gitignore_pattern_parent_relative_is_none() {
1225 assert_eq!(
1226 worktree_gitignore_pattern(std::path::Path::new("../external")),
1227 None
1228 );
1229 }
1230
1231 #[test]
1232 fn default_config_has_project_key() {
1233 let config = default_config("proj", "desc", "main", &[], "claude/coder");
1234 assert!(config.contains("project = \".apm/project.md\""));
1235 assert!(!config.contains("instructions = "));
1236 }
1237
1238 #[test]
1239 fn setup_claude_md_contains_new_imports() {
1240 let tmp = TempDir::new().unwrap();
1241 git_init(tmp.path());
1242 setup(tmp.path(), None, None, None, None).unwrap();
1243
1244 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1245 assert!(claude.contains("@.apm/project.md"));
1246 assert!(claude.contains("@.apm/agents/claude/apm.main-agent.md"));
1247 assert!(!claude.contains("@.apm/agents/claude/agents.md"));
1248 }
1249
1250 #[test]
1251 fn migrate_agents_md_config_instructions_to_project_key() {
1252 let tmp = TempDir::new().unwrap();
1253 git_init(tmp.path());
1254
1255 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1257 std::fs::write(
1258 tmp.path().join(".apm/config.toml"),
1259 "[agents]\ninstructions = \".apm/agents/default/agents.md\"\n",
1260 ).unwrap();
1261 std::fs::write(
1262 tmp.path().join("CLAUDE.md"),
1263 "@.apm/agents/default/agents.md\n",
1264 ).unwrap();
1265
1266 setup(tmp.path(), None, None, None, None).unwrap();
1267
1268 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1269 assert!(config.contains("project = \".apm/project.md\""));
1270 assert!(!config.contains("instructions = \".apm/agents/default/agents.md\""));
1271
1272 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1273 assert!(claude.contains("@.apm/project.md"));
1274 assert!(claude.contains("@.apm/agents/claude/apm.main-agent.md"));
1275 assert!(!claude.contains("@.apm/agents/default/agents.md"));
1276 }
1277
1278 #[test]
1279 fn migrate_project_md_from_agents_default() {
1280 let tmp = TempDir::new().unwrap();
1281 git_init(tmp.path());
1282 std::fs::create_dir_all(tmp.path().join(".apm/agents/default")).unwrap();
1283 std::fs::write(tmp.path().join(".apm/agents/default/apm.project.md"), "old location").unwrap();
1284 std::fs::write(tmp.path().join("CLAUDE.md"), "@.apm/agents/default/apm.project.md\n").unwrap();
1285 std::fs::write(
1286 tmp.path().join(".apm/config.toml"),
1287 "[project]\nname = \"test\"\ndefault_branch = \"main\"\n\n[agents]\nproject = \".apm/agents/default/apm.project.md\"\n",
1288 ).unwrap();
1289
1290 setup(tmp.path(), None, None, None, None).unwrap();
1291
1292 assert!(tmp.path().join(".apm/project.md").exists());
1293 assert!(!tmp.path().join(".apm/agents/default/apm.project.md").exists());
1294 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1295 assert!(claude.contains("@.apm/project.md"));
1296 assert!(!claude.contains("@.apm/agents/default/apm.project.md"));
1297 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1298 assert!(config.contains("project = \".apm/project.md\""));
1299 assert!(!config.contains("project = \".apm/agents/default/apm.project.md\""));
1300 }
1301
1302 #[test]
1303 fn setup_creates_valid_manifest_stubs() {
1304 let tmp = TempDir::new().unwrap();
1305 git_init(tmp.path());
1306 setup(tmp.path(), None, None, None, None).unwrap();
1307
1308 let spec_writer = std::fs::read_to_string(tmp.path().join(".apm/agents/claude/spec-writer.toml")).unwrap();
1309 let coder = std::fs::read_to_string(tmp.path().join(".apm/agents/claude/coder.toml")).unwrap();
1310
1311 let sw_val: toml::Value = toml::from_str(&spec_writer).expect("spec-writer.toml must be valid TOML");
1312 let co_val: toml::Value = toml::from_str(&coder).expect("coder.toml must be valid TOML");
1313
1314 assert_eq!(sw_val, toml::Value::Table(toml::map::Map::new()), "spec-writer.toml should parse to empty table");
1315 assert_eq!(co_val, toml::Value::Table(toml::map::Map::new()), "coder.toml should parse to empty table");
1316 }
1317
1318 #[test]
1319 fn setup_manifest_stub_output_messages() {
1320 let tmp = TempDir::new().unwrap();
1321 git_init(tmp.path());
1322 let result = setup(tmp.path(), None, None, None, None).unwrap();
1323
1324 assert!(
1325 result.messages.iter().any(|m| m.contains("spec-writer.toml")),
1326 "setup output must mention spec-writer.toml on first run"
1327 );
1328 assert!(
1329 result.messages.iter().any(|m| m.contains("coder.toml")),
1330 "setup output must mention coder.toml on first run"
1331 );
1332
1333 let result2 = setup(tmp.path(), None, None, None, None).unwrap();
1334 assert!(
1335 !result2.messages.iter().any(|m| m.contains("spec-writer.toml")),
1336 "setup output must not mention spec-writer.toml on re-run when unchanged"
1337 );
1338 assert!(
1339 !result2.messages.iter().any(|m| m.contains("coder.toml")),
1340 "setup output must not mention coder.toml on re-run when unchanged"
1341 );
1342 }
1343
1344 #[test]
1345 fn setup_does_not_overwrite_edited_manifest_stub() {
1346 let tmp = TempDir::new().unwrap();
1347 git_init(tmp.path());
1348 setup(tmp.path(), None, None, None, None).unwrap();
1349
1350 let coder_path = tmp.path().join(".apm/agents/claude/coder.toml");
1351 std::fs::write(&coder_path, "model = \"opus\"\n").unwrap();
1352
1353 setup(tmp.path(), None, None, None, None).unwrap();
1354
1355 assert_eq!(
1356 std::fs::read_to_string(&coder_path).unwrap(),
1357 "model = \"opus\"\n",
1358 "user-edited coder.toml must not be overwritten"
1359 );
1360 assert!(
1361 tmp.path().join(".apm/agents/claude/coder.toml.init").exists(),
1362 "coder.toml.init comparison copy must be written"
1363 );
1364 }
1365
1366 #[test]
1367 fn default_config_has_no_active_model_line() {
1368 let config = default_config("proj", "desc", "main", &[], "claude/coder");
1369 assert!(
1370 !config.lines().any(|l| l.trim_start().starts_with("model =")),
1371 "default_config must not emit an active model = line: {config}"
1372 );
1373 }
1374}