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