Skip to main content

apm_core/
init.rs

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