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("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
172fn 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
298pub 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
470pub 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 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 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 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 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 setup(tmp.path(), None, None, None).unwrap();
862
863 let workflow = tmp.path().join(".apm/workflow.toml");
865 std::fs::write(&workflow, "# custom workflow\n").unwrap();
866
867 setup(tmp.path(), None, None, None).unwrap();
869 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
870 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
872 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 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 setup(tmp.path(), None, None, None).unwrap();
891 assert!(tmp.path().join(".apm/config.toml.init").exists());
892 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
894 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 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 setup(tmp.path(), None, None, Some("alice")).unwrap();
909
910 setup(tmp.path(), None, None, None).unwrap();
912
913 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 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 assert!(tmp.path().join(".apm/config.toml.init").exists());
933 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 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 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 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 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 setup(tmp.path(), None, None, None).unwrap();
983 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}