Skip to main content

apm_core/
init.rs

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