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