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