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
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 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 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 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
165fn 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
291pub 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
437pub 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 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 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 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 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 setup(tmp.path(), None, None, None).unwrap();
829
830 let workflow = tmp.path().join(".apm/workflow.toml");
832 std::fs::write(&workflow, "# custom workflow\n").unwrap();
833
834 setup(tmp.path(), None, None, None).unwrap();
836 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
837 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
839 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 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 setup(tmp.path(), None, None, None).unwrap();
858 assert!(tmp.path().join(".apm/config.toml.init").exists());
859 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
861 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 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 setup(tmp.path(), None, None, Some("alice")).unwrap();
876
877 setup(tmp.path(), None, None, None).unwrap();
879
880 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 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 assert!(tmp.path().join(".apm/config.toml.init").exists());
900 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 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 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 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 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 setup(tmp.path(), None, None, None).unwrap();
950 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}