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