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