Skip to main content

apm_core/
init.rs

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
12/// Write `content` to `path`. If the file already exists and differs from the
13/// default, write a .init copy for comparison (always non-interactive in core).
14fn 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    // Always take the non-interactive path in the library: write .init copy.
27    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
33/// foo.toml → foo.toml.init, agents.md → agents.md.init
34fn 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    // Check if git_host is configured — if so, identity comes from the provider
55    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    // Only write local username when there is no git_host
63    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        // Extract project values from existing config to generate a
92        // comparable default (so the .init file has the right name/branch).
93        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
158/// Move old flat `.apm/agents.md`, `.apm/apm.spec-writer.md`, `.apm/apm.worker.md`,
159/// and `.apm/style.md` to `.apm/agents/default/` and rewrite path references in
160/// CLAUDE.md, config.toml, and workflow.toml.
161fn 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
268/// Rename `.apm/agents/default/` → `.apm/agents/claude/` and update
269/// CLAUDE.md and config.toml references.
270fn 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
372/// Returns the gitignore pattern for the worktree dir, or None if it is external.
373///
374/// External paths (absolute or parent-relative) don't need a gitignore entry.
375/// For in-repo paths, returns `Some("/<dir>/")`.
376pub 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
542/// Returns a map from transition `to` value → `on_failure` state name for every
543/// `Merge` or `PrOrEpicMerge` transition declared in the default workflow template.
544pub 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        // Write sentinel content to config
766        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        // Should not panic or error
803        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        // Second call should not overwrite
826        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        // First setup: creates all files
932        setup(tmp.path(), None, None, None, None).unwrap();
933
934        // Modify a file
935        let workflow = tmp.path().join(".apm/workflow.toml");
936        std::fs::write(&workflow, "# custom workflow\n").unwrap();
937
938        // Second setup (non-tty): should write .init copy
939        setup(tmp.path(), None, None, None, None).unwrap();
940        assert!(tmp.path().join(".apm/workflow.toml.init").exists());
941        // Original should be untouched
942        assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
943        // .init should have the default content
944        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        // Modify config.toml (add a custom section)
955        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        // Second setup (non-tty): should write config.toml.init
961        setup(tmp.path(), None, None, None, None).unwrap();
962        assert!(tmp.path().join(".apm/config.toml.init").exists());
963        // Original should be untouched
964        assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
965        // .init should be the default for this project's name/branch
966        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        // .init should carry the same collaborators as the live config
971        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        // First run: create config with collaborators = ["alice"]
979        setup(tmp.path(), None, None, Some("alice"), None).unwrap();
980
981        // Re-run without username (simulates non-TTY subsequent call)
982        setup(tmp.path(), None, None, None, None).unwrap();
983
984        // Should NOT produce a false diff
985        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        // Manually edit config to add a custom section (to trigger a real diff)
995        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        // .init must exist (real diff)
1003        assert!(tmp.path().join(".apm/config.toml.init").exists());
1004        // .init should contain alice's collaborators, not an empty array
1005        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        // Pre-create old flat files
1015        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        // Old flat files must be gone
1029        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        // New paths must exist (in agents/claude/ after migration)
1035        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        // CLAUDE.md must be rewritten — cascade migrates agents.md → two new imports, then default → claude
1041        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        // First run: no username → collaborators = []
1055        setup(tmp.path(), None, None, None, None).unwrap();
1056        // Re-run: should still be idempotent
1057        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        // Pre-create a config.toml with the old instructions key
1238        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}