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