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