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
490[workers]
491default = "{workers_default}"
492model = "sonnet"
493
494[logging]
495enabled = false
496file = "{log_file}"
497"##
498    )
499}
500
501fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
502    let path = apm_dir.join("local.toml");
503    if !path.exists() {
504        let username_escaped = toml_escape(username);
505        std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
506    }
507    Ok(())
508}
509
510pub fn default_workflow_toml() -> &'static str {
511    include_str!("default/workflow.toml")
512}
513
514/// Returns a map from transition `to` value → `on_failure` state name for every
515/// `Merge` or `PrOrEpicMerge` transition declared in the default workflow template.
516pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
517    #[derive(serde::Deserialize)]
518    struct Wrapper {
519        workflow: crate::config::WorkflowConfig,
520    }
521    let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
522        .expect("default workflow.toml is valid TOML");
523    let mut map = std::collections::HashMap::new();
524    for state in &w.workflow.states {
525        for tr in &state.transitions {
526            if matches!(
527                tr.completion,
528                crate::config::CompletionStrategy::Merge
529                    | crate::config::CompletionStrategy::PrOrEpicMerge
530            ) {
531                if let Some(ref of) = tr.on_failure {
532                    map.insert(tr.to.clone(), of.clone());
533                }
534            }
535        }
536    }
537    map
538}
539
540fn default_ticket_toml() -> &'static str {
541    include_str!("default/ticket.toml")
542}
543
544fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
545    if crate::git_util::has_commits(root) {
546        return Ok(());
547    }
548
549    crate::git_util::stage_files(root, &[
550        ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
551    ])?;
552
553    if crate::git_util::commit(root, "apm: initialize project").is_ok() {
554        messages.push("Created initial commit.".to_string());
555    }
556    Ok(())
557}
558
559fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
560    if let Ok(config) = crate::config::Config::load(root) {
561        let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
562        let wt_dir = main_root.join(&config.worktrees.dir);
563        if !wt_dir.exists() {
564            std::fs::create_dir_all(&wt_dir)?;
565            messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
566        }
567    }
568    Ok(())
569}
570
571pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
572    let mut messages: Vec<String> = Vec::new();
573    let apm_dir = root.join(".apm");
574    std::fs::create_dir_all(&apm_dir)?;
575    let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
576    if dockerfile_path.exists() {
577        messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
578        return Ok(SetupDockerOutput { messages });
579    }
580    std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
581    messages.push("Created .apm/Dockerfile.apm-worker".to_string());
582    messages.push(String::new());
583    messages.push("Next steps:".to_string());
584    messages.push("  1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
585    messages.push("  2. Build the image:".to_string());
586    messages.push("       docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
587    messages.push("  3. Add to .apm/config.toml:".to_string());
588    messages.push("       [workers]".to_string());
589    messages.push("       container = \"apm-worker\"".to_string());
590    messages.push("  4. Configure credential lookup (optional, macOS only):".to_string());
591    messages.push("       [workers.keychain]".to_string());
592    messages.push("       ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
593    Ok(SetupDockerOutput { messages })
594}
595
596const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
597
598# System tools
599RUN apt-get update && apt-get install -y \
600    curl git unzip ca-certificates && \
601    rm -rf /var/lib/apt/lists/*
602
603# Claude CLI
604RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
605
606# apm binary (replace with your version or a downloaded release)
607COPY target/release/apm /usr/local/bin/apm
608
609# Add project-specific dependencies here:
610# RUN apt-get install -y nodejs npm   # for Node projects
611# RUN pip install -r requirements.txt # for Python projects
612
613# gh CLI is NOT needed — the worker only runs local git commits;
614# push and PR creation happen on the host via apm state <id> implemented.
615
616WORKDIR /workspace
617"#;
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use std::process::Command;
623    use tempfile::TempDir;
624
625    fn git_init(dir: &Path) {
626        Command::new("git")
627            .args(["init", "-b", "main"])
628            .current_dir(dir)
629            .output()
630            .unwrap();
631        Command::new("git")
632            .args(["config", "user.email", "test@test.com"])
633            .current_dir(dir)
634            .output()
635            .unwrap();
636        Command::new("git")
637            .args(["config", "user.name", "Test"])
638            .current_dir(dir)
639            .output()
640            .unwrap();
641    }
642
643    #[test]
644    fn detect_default_branch_fresh_repo() {
645        let tmp = TempDir::new().unwrap();
646        git_init(tmp.path());
647        let branch = detect_default_branch(tmp.path());
648        assert_eq!(branch, "main");
649    }
650
651    #[test]
652    fn detect_default_branch_non_git() {
653        let tmp = TempDir::new().unwrap();
654        let branch = detect_default_branch(tmp.path());
655        assert_eq!(branch, "main");
656    }
657
658    #[test]
659    fn ensure_gitignore_creates_file() {
660        let tmp = TempDir::new().unwrap();
661        let path = tmp.path().join(".gitignore");
662        let mut msgs = Vec::new();
663        ensure_gitignore(&path, None, &mut msgs).unwrap();
664        let contents = std::fs::read_to_string(&path).unwrap();
665        assert!(contents.contains(".apm/local.toml"));
666        assert!(contents.contains(".apm/*.init"));
667        assert!(contents.contains(".apm/sessions.json"));
668        assert!(contents.contains(".apm/credentials.json"));
669    }
670
671    #[test]
672    fn ensure_gitignore_appends_missing_entry() {
673        let tmp = TempDir::new().unwrap();
674        let path = tmp.path().join(".gitignore");
675        std::fs::write(&path, "node_modules\n").unwrap();
676        let mut msgs = Vec::new();
677        ensure_gitignore(&path, None, &mut msgs).unwrap();
678        let contents = std::fs::read_to_string(&path).unwrap();
679        assert!(contents.contains("node_modules"));
680        assert!(contents.contains(".apm/local.toml"));
681    }
682
683    #[test]
684    fn ensure_gitignore_idempotent() {
685        let tmp = TempDir::new().unwrap();
686        let path = tmp.path().join(".gitignore");
687        let mut msgs = Vec::new();
688        ensure_gitignore(&path, None, &mut msgs).unwrap();
689        let before = std::fs::read_to_string(&path).unwrap();
690        ensure_gitignore(&path, None, &mut msgs).unwrap();
691        let after = std::fs::read_to_string(&path).unwrap();
692        assert_eq!(before, after);
693    }
694
695    #[test]
696    fn setup_creates_expected_files() {
697        let tmp = TempDir::new().unwrap();
698        git_init(tmp.path());
699        setup(tmp.path(), None, None, None, None).unwrap();
700
701        assert!(tmp.path().join("tickets").exists());
702        assert!(tmp.path().join(".apm/config.toml").exists());
703        assert!(tmp.path().join(".apm/workflow.toml").exists());
704        assert!(tmp.path().join(".apm/ticket.toml").exists());
705        assert!(!tmp.path().join(".apm/agents/claude/agents.md").exists());
706        assert!(tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
707        assert!(tmp.path().join(".apm/agents/claude/apm.worker.md").exists());
708        assert!(tmp.path().join(".apm/project.md").exists());
709        assert!(tmp.path().join(".apm/agents/claude/apm.main-agent.md").exists());
710        assert!(!tmp.path().join(".apm/agents.md").exists());
711        assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
712        assert!(!tmp.path().join(".apm/apm.worker.md").exists());
713        assert!(!tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
714        assert!(!tmp.path().join(".apm/agents/default/apm.worker.md").exists());
715        assert!(tmp.path().join(".gitignore").exists());
716        assert!(tmp.path().join("CLAUDE.md").exists());
717    }
718
719    #[test]
720    fn setup_non_tty_uses_dir_name_and_empty_description() {
721        let tmp = TempDir::new().unwrap();
722        git_init(tmp.path());
723        setup(tmp.path(), None, None, None, None).unwrap();
724
725        let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
726        let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
727        assert!(config.contains(&format!("name = \"{dir_name}\"")));
728        assert!(config.contains("description = \"\""));
729    }
730
731    #[test]
732    fn setup_is_idempotent() {
733        let tmp = TempDir::new().unwrap();
734        git_init(tmp.path());
735        setup(tmp.path(), None, None, None, None).unwrap();
736
737        // Write sentinel content to config
738        let config_path = tmp.path().join(".apm/config.toml");
739        let original = std::fs::read_to_string(&config_path).unwrap();
740
741        setup(tmp.path(), None, None, None, None).unwrap();
742        let after = std::fs::read_to_string(&config_path).unwrap();
743        assert_eq!(original, after);
744    }
745
746    #[test]
747    fn migrate_moves_files_and_updates_claude_md() {
748        let tmp = TempDir::new().unwrap();
749        git_init(tmp.path());
750
751        std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
752        std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
753        std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
754
755        migrate(tmp.path()).unwrap();
756
757        assert!(tmp.path().join(".apm/config.toml").exists());
758        assert!(tmp.path().join(".apm/agents.md").exists());
759        assert!(!tmp.path().join("apm.toml").exists());
760        assert!(!tmp.path().join("apm.agents.md").exists());
761
762        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
763        assert!(claude.contains("@.apm/agents.md"));
764        assert!(!claude.contains("@apm.agents.md"));
765    }
766
767    #[test]
768    fn migrate_already_migrated() {
769        let tmp = TempDir::new().unwrap();
770        git_init(tmp.path());
771        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
772        std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
773
774        // Should not panic or error
775        migrate(tmp.path()).unwrap();
776    }
777
778    #[test]
779    fn setup_docker_creates_dockerfile() {
780        let tmp = TempDir::new().unwrap();
781        git_init(tmp.path());
782        setup_docker(tmp.path()).unwrap();
783        let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
784        assert!(dockerfile.exists());
785        let contents = std::fs::read_to_string(&dockerfile).unwrap();
786        assert!(contents.contains("FROM rust:1.82-slim"));
787        assert!(contents.contains("claude"));
788        assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
789    }
790
791    #[test]
792    fn setup_docker_idempotent() {
793        let tmp = TempDir::new().unwrap();
794        git_init(tmp.path());
795        setup_docker(tmp.path()).unwrap();
796        let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
797        // Second call should not overwrite
798        setup_docker(tmp.path()).unwrap();
799        let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
800        assert_eq!(before, after);
801    }
802
803    #[test]
804    fn default_config_escapes_special_chars() {
805        let name = r#"my\"project"#;
806        let description = r#"desc with "quotes" and \backslash"#;
807        let branch = "main";
808        let config = default_config(name, description, branch, &[], "claude/worker");
809        toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
810    }
811
812    #[test]
813    fn write_local_toml_creates_file() {
814        let tmp = TempDir::new().unwrap();
815        write_local_toml(tmp.path(), "alice").unwrap();
816        let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
817        assert!(contents.contains("username = \"alice\""));
818    }
819
820    #[test]
821    fn write_local_toml_idempotent() {
822        let tmp = TempDir::new().unwrap();
823        write_local_toml(tmp.path(), "alice").unwrap();
824        let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
825        write_local_toml(tmp.path(), "bob").unwrap();
826        let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
827        assert_eq!(first, second);
828        assert!(second.contains("alice"));
829    }
830
831    #[test]
832    fn setup_non_tty_no_local_toml() {
833        let tmp = TempDir::new().unwrap();
834        git_init(tmp.path());
835        setup(tmp.path(), None, None, None, None).unwrap();
836        assert!(!tmp.path().join(".apm/local.toml").exists());
837    }
838
839    #[test]
840    fn default_config_with_collaborators() {
841        let config = default_config("proj", "desc", "main", &["alice"], "claude/worker");
842        let parsed: toml::Value = toml::from_str(&config).unwrap();
843        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
844        assert_eq!(collaborators.len(), 1);
845        assert_eq!(collaborators[0].as_str().unwrap(), "alice");
846    }
847
848    #[test]
849    fn default_config_empty_collaborators() {
850        let config = default_config("proj", "desc", "main", &[], "claude/worker");
851        let parsed: toml::Value = toml::from_str(&config).unwrap();
852        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
853        assert!(collaborators.is_empty());
854    }
855
856    #[test]
857    fn write_default_creates_new_file() {
858        let tmp = TempDir::new().unwrap();
859        let path = tmp.path().join("test.toml");
860        let mut msgs = Vec::new();
861        write_default(&path, "content", "test.toml", &mut msgs).unwrap();
862        assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
863        assert!(msgs.iter().any(|m| m.contains("Created")));
864    }
865
866    #[test]
867    fn write_default_unchanged_when_identical() {
868        let tmp = TempDir::new().unwrap();
869        let path = tmp.path().join("test.toml");
870        std::fs::write(&path, "content").unwrap();
871        let mut msgs = Vec::new();
872        write_default(&path, "content", "test.toml", &mut msgs).unwrap();
873        assert!(msgs.is_empty());
874    }
875
876    #[test]
877    fn write_default_non_tty_writes_init_when_differs() {
878        let tmp = TempDir::new().unwrap();
879        let path = tmp.path().join("test.toml");
880        std::fs::write(&path, "modified").unwrap();
881        let mut msgs = Vec::new();
882        write_default(&path, "default", "test.toml", &mut msgs).unwrap();
883        assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
884        assert_eq!(
885            std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
886            "default"
887        );
888    }
889
890    #[test]
891    fn init_path_for_preserves_extension() {
892        let p = std::path::Path::new("/a/b/workflow.toml");
893        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
894
895        let p = std::path::Path::new("/a/b/agents.md");
896        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
897    }
898
899    #[test]
900    fn setup_writes_init_files_when_content_differs() {
901        let tmp = TempDir::new().unwrap();
902        git_init(tmp.path());
903        // First setup: creates all files
904        setup(tmp.path(), None, None, None, None).unwrap();
905
906        // Modify a file
907        let workflow = tmp.path().join(".apm/workflow.toml");
908        std::fs::write(&workflow, "# custom workflow\n").unwrap();
909
910        // Second setup (non-tty): should write .init copy
911        setup(tmp.path(), None, None, None, None).unwrap();
912        assert!(tmp.path().join(".apm/workflow.toml.init").exists());
913        // Original should be untouched
914        assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
915        // .init should have the default content
916        let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
917        assert_eq!(init_content, default_workflow_toml());
918    }
919
920    #[test]
921    fn setup_writes_config_init_when_modified() {
922        let tmp = TempDir::new().unwrap();
923        git_init(tmp.path());
924        setup(tmp.path(), None, None, None, None).unwrap();
925
926        // Modify config.toml (add a custom section)
927        let config_path = tmp.path().join(".apm/config.toml");
928        let mut content = std::fs::read_to_string(&config_path).unwrap();
929        content.push_str("\n[custom]\nfoo = \"bar\"\n");
930        std::fs::write(&config_path, &content).unwrap();
931
932        // Second setup (non-tty): should write config.toml.init
933        setup(tmp.path(), None, None, None, None).unwrap();
934        assert!(tmp.path().join(".apm/config.toml.init").exists());
935        // Original should be untouched
936        assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
937        // .init should be the default for this project's name/branch
938        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
939        assert!(!init_content.contains("[custom]"));
940        assert!(init_content.contains("[project]"));
941        assert!(init_content.contains("[workers]"));
942        // .init should carry the same collaborators as the live config
943        assert!(init_content.contains("collaborators = []"));
944    }
945
946    #[test]
947    fn setup_no_false_diff_when_collaborators_present() {
948        let tmp = TempDir::new().unwrap();
949        git_init(tmp.path());
950        // First run: create config with collaborators = ["alice"]
951        setup(tmp.path(), None, None, Some("alice"), None).unwrap();
952
953        // Re-run without username (simulates non-TTY subsequent call)
954        setup(tmp.path(), None, None, None, None).unwrap();
955
956        // Should NOT produce a false diff
957        assert!(!tmp.path().join(".apm/config.toml.init").exists());
958    }
959
960    #[test]
961    fn setup_config_init_collaborators_match_live() {
962        let tmp = TempDir::new().unwrap();
963        git_init(tmp.path());
964        setup(tmp.path(), None, None, Some("alice"), None).unwrap();
965
966        // Manually edit config to add a custom section (to trigger a real diff)
967        let config_path = tmp.path().join(".apm/config.toml");
968        let mut content = std::fs::read_to_string(&config_path).unwrap();
969        content.push_str("\n[custom]\nfoo = \"bar\"\n");
970        std::fs::write(&config_path, &content).unwrap();
971
972        setup(tmp.path(), None, None, None, None).unwrap();
973
974        // .init must exist (real diff)
975        assert!(tmp.path().join(".apm/config.toml.init").exists());
976        // .init should contain alice's collaborators, not an empty array
977        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
978        assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
979    }
980
981    #[test]
982    fn setup_migrates_flat_agent_files_to_agents_default() {
983        let tmp = TempDir::new().unwrap();
984        git_init(tmp.path());
985
986        // Pre-create old flat files
987        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
988        std::fs::write(tmp.path().join(".apm/agents.md"), "# agents\n").unwrap();
989        std::fs::write(tmp.path().join(".apm/apm.spec-writer.md"), "# spec\n").unwrap();
990        std::fs::write(tmp.path().join(".apm/apm.worker.md"), "# worker\n").unwrap();
991        std::fs::write(tmp.path().join(".apm/style.md"), "# style\n").unwrap();
992        std::fs::write(
993            tmp.path().join("CLAUDE.md"),
994            "@.apm/agents.md\n@.apm/style.md\n",
995        )
996        .unwrap();
997
998        setup(tmp.path(), None, None, None, None).unwrap();
999
1000        // Old flat files must be gone
1001        assert!(!tmp.path().join(".apm/agents.md").exists());
1002        assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
1003        assert!(!tmp.path().join(".apm/apm.worker.md").exists());
1004        assert!(!tmp.path().join(".apm/style.md").exists());
1005
1006        // New paths must exist (in agents/claude/ after migration)
1007        assert!(tmp.path().join(".apm/agents/claude/agents.md").exists());
1008        assert!(tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
1009        assert!(tmp.path().join(".apm/agents/claude/apm.worker.md").exists());
1010        assert!(tmp.path().join(".apm/agents/claude/style.md").exists());
1011
1012        // CLAUDE.md must be rewritten — cascade migrates agents.md → two new imports, then default → claude
1013        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1014        assert!(claude.contains("@.apm/project.md"));
1015        assert!(claude.contains("@.apm/agents/claude/apm.main-agent.md"));
1016        assert!(!claude.contains("@.apm/agents/claude/agents.md"));
1017        assert!(claude.contains("@.apm/agents/claude/style.md"));
1018        assert!(!claude.contains("@.apm/agents.md"));
1019        assert!(!claude.contains("@.apm/style.md"));
1020    }
1021
1022    #[test]
1023    fn setup_no_false_diff_empty_collaborators() {
1024        let tmp = TempDir::new().unwrap();
1025        git_init(tmp.path());
1026        // First run: no username → collaborators = []
1027        setup(tmp.path(), None, None, None, None).unwrap();
1028        // Re-run: should still be idempotent
1029        setup(tmp.path(), None, None, None, None).unwrap();
1030        assert!(!tmp.path().join(".apm/config.toml.init").exists());
1031    }
1032
1033    #[test]
1034    fn default_workflow_toml_is_valid() {
1035        use crate::config::{SatisfiesDeps, WorkflowFile};
1036
1037        let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1038        let states = &parsed.workflow.states;
1039
1040        let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
1041        assert_eq!(
1042            ids,
1043            ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
1044        );
1045
1046        for id in ["groomed", "ammend"] {
1047            let s = states.iter().find(|s| s.id == id).unwrap();
1048            assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
1049        }
1050
1051        for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
1052            let s = states.iter().find(|s| s.id == id).unwrap();
1053            assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
1054        }
1055    }
1056
1057    #[test]
1058    fn default_workflow_all_transitions_have_valid_outcomes() {
1059        use crate::config::{resolve_outcome, WorkflowFile};
1060
1061        let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1062        let states = &parsed.workflow.states;
1063        let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
1064            states.iter().map(|s| (s.id.as_str(), s)).collect();
1065
1066        let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"];
1067
1068        for state in states {
1069            for t in &state.transitions {
1070                let target = state_map
1071                    .get(t.to.as_str())
1072                    .unwrap_or_else(|| panic!("target state '{}' not found in map", t.to));
1073                let outcome = resolve_outcome(t, target);
1074                assert!(
1075                    !outcome.is_empty(),
1076                    "transition {} → {} has empty outcome",
1077                    state.id, t.to
1078                );
1079                assert!(
1080                    valid_outcomes.contains(&outcome),
1081                    "transition {} → {} has unexpected outcome '{outcome}'",
1082                    state.id, t.to
1083                );
1084            }
1085        }
1086    }
1087
1088    #[test]
1089    fn default_ticket_toml_is_valid() {
1090        use crate::config::TicketFile;
1091
1092        let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
1093        let sections = &parsed.ticket.sections;
1094
1095        for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
1096            let s = sections.iter().find(|s| s.name == name).unwrap();
1097            assert!(s.required, "section '{name}' should be required");
1098        }
1099    }
1100
1101    #[test]
1102    fn default_config_has_in_repo_worktrees_dir() {
1103        let config = default_config("myproj", "desc", "main", &[], "claude/worker");
1104        assert!(
1105            config.contains("dir = \".apm--worktrees\""),
1106            "default config should use .apm--worktrees dir: {config}"
1107        );
1108    }
1109
1110    #[test]
1111    fn setup_gitignore_includes_worktrees_pattern() {
1112        let tmp = TempDir::new().unwrap();
1113        git_init(tmp.path());
1114        setup(tmp.path(), None, None, None, None).unwrap();
1115        let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
1116        assert!(contents.contains("/.apm--worktrees/"), ".gitignore must contain /.apm--worktrees/");
1117        assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
1118    }
1119
1120    #[test]
1121    fn ensure_gitignore_worktrees_idempotent() {
1122        let tmp = TempDir::new().unwrap();
1123        let path = tmp.path().join(".gitignore");
1124        let mut msgs = Vec::new();
1125        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1126        let before = std::fs::read_to_string(&path).unwrap();
1127        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1128        let after = std::fs::read_to_string(&path).unwrap();
1129        assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
1130        let count = before.matches("/worktrees/").count();
1131        assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
1132    }
1133
1134    #[test]
1135    fn setup_creates_worktrees_dir_inside_repo() {
1136        let tmp = TempDir::new().unwrap();
1137        git_init(tmp.path());
1138        setup(tmp.path(), None, None, None, None).unwrap();
1139        assert!(
1140            tmp.path().join(".apm--worktrees").exists(),
1141            "worktrees dir should be created inside the repo"
1142        );
1143    }
1144
1145    #[test]
1146    fn worktree_gitignore_pattern_simple() {
1147        assert_eq!(
1148            worktree_gitignore_pattern(std::path::Path::new("worktrees")),
1149            Some("/worktrees/".to_string())
1150        );
1151    }
1152
1153    #[test]
1154    fn worktree_gitignore_pattern_hidden_dir() {
1155        assert_eq!(
1156            worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
1157            Some("/.apm--worktrees/".to_string())
1158        );
1159    }
1160
1161    #[test]
1162    fn worktree_gitignore_pattern_nested() {
1163        assert_eq!(
1164            worktree_gitignore_pattern(std::path::Path::new("build/wt")),
1165            Some("/build/wt/".to_string())
1166        );
1167    }
1168
1169    #[test]
1170    fn worktree_gitignore_pattern_absolute_is_none() {
1171        assert_eq!(
1172            worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
1173            None
1174        );
1175    }
1176
1177    #[test]
1178    fn worktree_gitignore_pattern_parent_relative_is_none() {
1179        assert_eq!(
1180            worktree_gitignore_pattern(std::path::Path::new("../external")),
1181            None
1182        );
1183    }
1184
1185    #[test]
1186    fn default_config_has_project_key() {
1187        let config = default_config("proj", "desc", "main", &[], "claude/worker");
1188        assert!(config.contains("project = \".apm/project.md\""));
1189        assert!(!config.contains("instructions = "));
1190    }
1191
1192    #[test]
1193    fn setup_claude_md_contains_new_imports() {
1194        let tmp = TempDir::new().unwrap();
1195        git_init(tmp.path());
1196        setup(tmp.path(), None, None, None, None).unwrap();
1197
1198        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1199        assert!(claude.contains("@.apm/project.md"));
1200        assert!(claude.contains("@.apm/agents/claude/apm.main-agent.md"));
1201        assert!(!claude.contains("@.apm/agents/claude/agents.md"));
1202    }
1203
1204    #[test]
1205    fn migrate_agents_md_config_instructions_to_project_key() {
1206        let tmp = TempDir::new().unwrap();
1207        git_init(tmp.path());
1208
1209        // Pre-create a config.toml with the old instructions key
1210        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1211        std::fs::write(
1212            tmp.path().join(".apm/config.toml"),
1213            "[agents]\ninstructions = \".apm/agents/default/agents.md\"\n",
1214        ).unwrap();
1215        std::fs::write(
1216            tmp.path().join("CLAUDE.md"),
1217            "@.apm/agents/default/agents.md\n",
1218        ).unwrap();
1219
1220        setup(tmp.path(), None, None, None, None).unwrap();
1221
1222        let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1223        assert!(config.contains("project = \".apm/project.md\""));
1224        assert!(!config.contains("instructions = \".apm/agents/default/agents.md\""));
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/default/agents.md"));
1230    }
1231
1232    #[test]
1233    fn migrate_project_md_from_agents_default() {
1234        let tmp = TempDir::new().unwrap();
1235        git_init(tmp.path());
1236        std::fs::create_dir_all(tmp.path().join(".apm/agents/default")).unwrap();
1237        std::fs::write(tmp.path().join(".apm/agents/default/apm.project.md"), "old location").unwrap();
1238        std::fs::write(tmp.path().join("CLAUDE.md"), "@.apm/agents/default/apm.project.md\n").unwrap();
1239        std::fs::write(
1240            tmp.path().join(".apm/config.toml"),
1241            "[project]\nname = \"test\"\ndefault_branch = \"main\"\n\n[agents]\nproject = \".apm/agents/default/apm.project.md\"\n",
1242        ).unwrap();
1243
1244        setup(tmp.path(), None, None, None, None).unwrap();
1245
1246        assert!(tmp.path().join(".apm/project.md").exists());
1247        assert!(!tmp.path().join(".apm/agents/default/apm.project.md").exists());
1248        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1249        assert!(claude.contains("@.apm/project.md"));
1250        assert!(!claude.contains("@.apm/agents/default/apm.project.md"));
1251        let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1252        assert!(config.contains("project = \".apm/project.md\""));
1253        assert!(!config.contains("project = \".apm/agents/default/apm.project.md\""));
1254    }
1255}