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 write_default(&apm_dir.join("agents.md"), default_agents_md(), ".apm/agents.md", &mut messages)?;
133 write_default(&apm_dir.join("apm.spec-writer.md"), include_str!("default/apm.spec-writer.md"), ".apm/apm.spec-writer.md", &mut messages)?;
134 write_default(&apm_dir.join("apm.worker.md"), include_str!("default/apm.worker.md"), ".apm/apm.worker.md", &mut messages)?;
135 ensure_claude_md(root, ".apm/agents.md", &mut messages)?;
136 let gitignore = root.join(".gitignore");
137 let wt_pattern = crate::config::Config::load(root)
138 .ok()
139 .and_then(|c| worktree_gitignore_pattern(&c.worktrees.dir));
140 ensure_gitignore(&gitignore, wt_pattern.as_deref(), &mut messages)?;
141 maybe_initial_commit(root, &mut messages)?;
142 ensure_worktrees_dir(root, &mut messages)?;
143 Ok(SetupOutput { messages })
144}
145
146pub fn migrate(root: &Path) -> Result<Vec<String>> {
147 let mut messages: Vec<String> = Vec::new();
148 let apm_dir = root.join(".apm");
149 let new_config = apm_dir.join("config.toml");
150
151 if new_config.exists() {
152 messages.push("Already migrated.".to_string());
153 return Ok(messages);
154 }
155
156 let old_config = root.join("apm.toml");
157 let old_agents = root.join("apm.agents.md");
158
159 if !old_config.exists() && !old_agents.exists() {
160 messages.push("Nothing to migrate.".to_string());
161 return Ok(messages);
162 }
163
164 std::fs::create_dir_all(&apm_dir)?;
165
166 if old_config.exists() {
167 std::fs::rename(&old_config, &new_config)?;
168 messages.push("Moved apm.toml → .apm/config.toml".to_string());
169 }
170
171 if old_agents.exists() {
172 let new_agents = apm_dir.join("agents.md");
173 std::fs::rename(&old_agents, &new_agents)?;
174 messages.push("Moved apm.agents.md → .apm/agents.md".to_string());
175 }
176
177 let claude_path = root.join("CLAUDE.md");
178 if claude_path.exists() {
179 let contents = std::fs::read_to_string(&claude_path)?;
180 if contents.contains("@apm.agents.md") {
181 let updated = contents.replace("@apm.agents.md", "@.apm/agents.md");
182 std::fs::write(&claude_path, updated)?;
183 messages.push("Updated CLAUDE.md (@apm.agents.md → @.apm/agents.md)".to_string());
184 }
185 }
186
187 Ok(messages)
188}
189
190pub fn detect_default_branch(root: &Path) -> String {
191 crate::git_util::current_branch(root)
192 .ok()
193 .filter(|s| !s.is_empty())
194 .unwrap_or_else(|| "main".to_string())
195}
196
197pub fn worktree_gitignore_pattern(dir: &Path) -> Option<String> {
202 let s = dir.to_string_lossy();
203 if s.starts_with('/') || s.starts_with("..") {
204 return None;
205 }
206 Some(format!("/{s}/"))
207}
208
209pub fn ensure_gitignore(path: &Path, worktree_pattern: Option<&str>, messages: &mut Vec<String>) -> Result<()> {
210 let static_entries = ["tickets/NEXT_ID", ".apm/local.toml", ".apm/epics.toml", ".apm/*.init", ".apm/sessions.json", ".apm/credentials.json"];
211 let mut entries: Vec<&str> = static_entries.to_vec();
212 let owned_pattern;
213 if let Some(p) = worktree_pattern {
214 entries.push("# apm worktrees");
215 owned_pattern = p.to_owned();
216 entries.push(&owned_pattern);
217 }
218 if path.exists() {
219 let mut contents = std::fs::read_to_string(path)?;
220 let mut changed = false;
221 for entry in &entries {
222 if !contents.contains(entry) {
223 if !contents.ends_with('\n') {
224 contents.push('\n');
225 }
226 contents.push_str(entry);
227 contents.push('\n');
228 changed = true;
229 }
230 }
231 if changed {
232 std::fs::write(path, &contents)?;
233 messages.push("Updated .gitignore".to_string());
234 }
235 } else {
236 std::fs::write(path, entries.join("\n") + "\n")?;
237 messages.push("Created .gitignore".to_string());
238 }
239 Ok(())
240}
241
242fn ensure_claude_md(root: &Path, agents_path: &str, messages: &mut Vec<String>) -> Result<()> {
243 let import_line = format!("@{agents_path}");
244 let claude_path = root.join("CLAUDE.md");
245 if claude_path.exists() {
246 let contents = std::fs::read_to_string(&claude_path)?;
247 if contents.contains(&import_line) {
248 return Ok(());
249 }
250 std::fs::write(&claude_path, format!("{import_line}\n\n{contents}"))?;
251 messages.push(format!("Updated CLAUDE.md (added {import_line} import)."));
252 } else {
253 std::fs::write(&claude_path, format!("{import_line}\n"))?;
254 messages.push("Created CLAUDE.md.".to_string());
255 }
256 Ok(())
257}
258
259fn default_agents_md() -> &'static str {
260 include_str!("default/apm.agents.md")
261}
262
263#[cfg(target_os = "macos")]
264fn default_log_file(name: &str) -> String {
265 format!("~/Library/Logs/apm/{name}.log")
266}
267
268#[cfg(not(target_os = "macos"))]
269fn default_log_file(name: &str) -> String {
270 format!("~/.local/state/apm/{name}.log")
271}
272
273fn toml_escape(s: &str) -> String {
274 s.replace('\\', "\\\\").replace('"', "\\\"")
275}
276
277fn default_config(name: &str, description: &str, default_branch: &str, collaborators: &[&str]) -> String {
278 let log_file = default_log_file(name);
279 let name = toml_escape(name);
280 let description = toml_escape(description);
281 let default_branch = toml_escape(default_branch);
282 let log_file = toml_escape(&log_file);
283 let collaborators_line = {
284 let items: Vec<String> = collaborators.iter().map(|u| format!("\"{}\"", toml_escape(u))).collect();
285 format!("collaborators = [{}]", items.join(", "))
286 };
287 format!(
288 r##"[project]
289name = "{name}"
290description = "{description}"
291default_branch = "{default_branch}"
292{collaborators_line}
293
294[tickets]
295dir = "tickets"
296archive_dir = "archive/tickets"
297
298[worktrees]
299dir = "worktrees"
300agent_dirs = [".claude", ".cursor", ".windsurf"]
301
302[agents]
303max_concurrent = 3
304max_workers_per_epic = 1
305max_workers_on_default = 1
306instructions = ".apm/agents.md"
307
308[workers]
309command = "claude"
310args = ["--print"]
311
312[worker_profiles.spec_agent]
313command = "claude"
314args = ["--print"]
315instructions = ".apm/apm.spec-writer.md"
316role_prefix = "You are a Spec-Writer agent assigned to ticket #<id>."
317
318[worker_profiles.impl_agent]
319command = "claude"
320args = ["--print"]
321instructions = ".apm/apm.worker.md"
322role_prefix = "You are a Worker agent assigned to ticket #<id>."
323
324[logging]
325enabled = false
326file = "{log_file}"
327"##
328 )
329}
330
331fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
332 let path = apm_dir.join("local.toml");
333 if !path.exists() {
334 let username_escaped = toml_escape(username);
335 std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
336 }
337 Ok(())
338}
339
340pub fn default_workflow_toml() -> &'static str {
341 include_str!("default/workflow.toml")
342}
343
344pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
347 #[derive(serde::Deserialize)]
348 struct Wrapper {
349 workflow: crate::config::WorkflowConfig,
350 }
351 let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
352 .expect("default workflow.toml is valid TOML");
353 let mut map = std::collections::HashMap::new();
354 for state in &w.workflow.states {
355 for tr in &state.transitions {
356 if matches!(
357 tr.completion,
358 crate::config::CompletionStrategy::Merge
359 | crate::config::CompletionStrategy::PrOrEpicMerge
360 ) {
361 if let Some(ref of) = tr.on_failure {
362 map.insert(tr.to.clone(), of.clone());
363 }
364 }
365 }
366 }
367 map
368}
369
370fn default_ticket_toml() -> &'static str {
371 include_str!("default/ticket.toml")
372}
373
374fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
375 if crate::git_util::has_commits(root) {
376 return Ok(());
377 }
378
379 crate::git_util::stage_files(root, &[
380 ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
381 ])?;
382
383 if crate::git_util::commit(root, "apm: initialize project").is_ok() {
384 messages.push("Created initial commit.".to_string());
385 }
386 Ok(())
387}
388
389fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
390 if let Ok(config) = crate::config::Config::load(root) {
391 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
392 let wt_dir = main_root.join(&config.worktrees.dir);
393 if !wt_dir.exists() {
394 std::fs::create_dir_all(&wt_dir)?;
395 messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
396 }
397 }
398 Ok(())
399}
400
401pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
402 let mut messages: Vec<String> = Vec::new();
403 let apm_dir = root.join(".apm");
404 std::fs::create_dir_all(&apm_dir)?;
405 let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
406 if dockerfile_path.exists() {
407 messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
408 return Ok(SetupDockerOutput { messages });
409 }
410 std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
411 messages.push("Created .apm/Dockerfile.apm-worker".to_string());
412 messages.push(String::new());
413 messages.push("Next steps:".to_string());
414 messages.push(" 1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
415 messages.push(" 2. Build the image:".to_string());
416 messages.push(" docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
417 messages.push(" 3. Add to .apm/config.toml:".to_string());
418 messages.push(" [workers]".to_string());
419 messages.push(" container = \"apm-worker\"".to_string());
420 messages.push(" 4. Configure credential lookup (optional, macOS only):".to_string());
421 messages.push(" [workers.keychain]".to_string());
422 messages.push(" ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
423 Ok(SetupDockerOutput { messages })
424}
425
426const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
427
428# System tools
429RUN apt-get update && apt-get install -y \
430 curl git unzip ca-certificates && \
431 rm -rf /var/lib/apt/lists/*
432
433# Claude CLI
434RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
435
436# apm binary (replace with your version or a downloaded release)
437COPY target/release/apm /usr/local/bin/apm
438
439# Add project-specific dependencies here:
440# RUN apt-get install -y nodejs npm # for Node projects
441# RUN pip install -r requirements.txt # for Python projects
442
443# gh CLI is NOT needed — the worker only runs local git commits;
444# push and PR creation happen on the host via apm state <id> implemented.
445
446WORKDIR /workspace
447"#;
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use std::process::Command;
453 use tempfile::TempDir;
454
455 fn git_init(dir: &Path) {
456 Command::new("git")
457 .args(["init", "-b", "main"])
458 .current_dir(dir)
459 .output()
460 .unwrap();
461 Command::new("git")
462 .args(["config", "user.email", "test@test.com"])
463 .current_dir(dir)
464 .output()
465 .unwrap();
466 Command::new("git")
467 .args(["config", "user.name", "Test"])
468 .current_dir(dir)
469 .output()
470 .unwrap();
471 }
472
473 #[test]
474 fn detect_default_branch_fresh_repo() {
475 let tmp = TempDir::new().unwrap();
476 git_init(tmp.path());
477 let branch = detect_default_branch(tmp.path());
478 assert_eq!(branch, "main");
479 }
480
481 #[test]
482 fn detect_default_branch_non_git() {
483 let tmp = TempDir::new().unwrap();
484 let branch = detect_default_branch(tmp.path());
485 assert_eq!(branch, "main");
486 }
487
488 #[test]
489 fn ensure_gitignore_creates_file() {
490 let tmp = TempDir::new().unwrap();
491 let path = tmp.path().join(".gitignore");
492 let mut msgs = Vec::new();
493 ensure_gitignore(&path, None, &mut msgs).unwrap();
494 let contents = std::fs::read_to_string(&path).unwrap();
495 assert!(contents.contains("tickets/NEXT_ID"));
496 assert!(contents.contains(".apm/local.toml"));
497 assert!(contents.contains(".apm/*.init"));
498 assert!(contents.contains(".apm/sessions.json"));
499 assert!(contents.contains(".apm/credentials.json"));
500 }
501
502 #[test]
503 fn ensure_gitignore_appends_missing_entry() {
504 let tmp = TempDir::new().unwrap();
505 let path = tmp.path().join(".gitignore");
506 std::fs::write(&path, "node_modules\n").unwrap();
507 let mut msgs = Vec::new();
508 ensure_gitignore(&path, None, &mut msgs).unwrap();
509 let contents = std::fs::read_to_string(&path).unwrap();
510 assert!(contents.contains("node_modules"));
511 assert!(contents.contains("tickets/NEXT_ID"));
512 }
513
514 #[test]
515 fn ensure_gitignore_idempotent() {
516 let tmp = TempDir::new().unwrap();
517 let path = tmp.path().join(".gitignore");
518 let mut msgs = Vec::new();
519 ensure_gitignore(&path, None, &mut msgs).unwrap();
520 let before = std::fs::read_to_string(&path).unwrap();
521 ensure_gitignore(&path, None, &mut msgs).unwrap();
522 let after = std::fs::read_to_string(&path).unwrap();
523 assert_eq!(before, after);
524 }
525
526 #[test]
527 fn setup_creates_expected_files() {
528 let tmp = TempDir::new().unwrap();
529 git_init(tmp.path());
530 setup(tmp.path(), None, None, None).unwrap();
531
532 assert!(tmp.path().join("tickets").exists());
533 assert!(tmp.path().join(".apm/config.toml").exists());
534 assert!(tmp.path().join(".apm/workflow.toml").exists());
535 assert!(tmp.path().join(".apm/ticket.toml").exists());
536 assert!(tmp.path().join(".apm/agents.md").exists());
537 assert!(tmp.path().join(".apm/apm.spec-writer.md").exists());
538 assert!(tmp.path().join(".apm/apm.worker.md").exists());
539 assert!(tmp.path().join(".gitignore").exists());
540 assert!(tmp.path().join("CLAUDE.md").exists());
541 }
542
543 #[test]
544 fn setup_non_tty_uses_dir_name_and_empty_description() {
545 let tmp = TempDir::new().unwrap();
546 git_init(tmp.path());
547 setup(tmp.path(), None, None, None).unwrap();
548
549 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
550 let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
551 assert!(config.contains(&format!("name = \"{dir_name}\"")));
552 assert!(config.contains("description = \"\""));
553 }
554
555 #[test]
556 fn setup_is_idempotent() {
557 let tmp = TempDir::new().unwrap();
558 git_init(tmp.path());
559 setup(tmp.path(), None, None, None).unwrap();
560
561 let config_path = tmp.path().join(".apm/config.toml");
563 let original = std::fs::read_to_string(&config_path).unwrap();
564
565 setup(tmp.path(), None, None, None).unwrap();
566 let after = std::fs::read_to_string(&config_path).unwrap();
567 assert_eq!(original, after);
568 }
569
570 #[test]
571 fn migrate_moves_files_and_updates_claude_md() {
572 let tmp = TempDir::new().unwrap();
573 git_init(tmp.path());
574
575 std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
576 std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
577 std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
578
579 migrate(tmp.path()).unwrap();
580
581 assert!(tmp.path().join(".apm/config.toml").exists());
582 assert!(tmp.path().join(".apm/agents.md").exists());
583 assert!(!tmp.path().join("apm.toml").exists());
584 assert!(!tmp.path().join("apm.agents.md").exists());
585
586 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
587 assert!(claude.contains("@.apm/agents.md"));
588 assert!(!claude.contains("@apm.agents.md"));
589 }
590
591 #[test]
592 fn migrate_already_migrated() {
593 let tmp = TempDir::new().unwrap();
594 git_init(tmp.path());
595 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
596 std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
597
598 migrate(tmp.path()).unwrap();
600 }
601
602 #[test]
603 fn setup_docker_creates_dockerfile() {
604 let tmp = TempDir::new().unwrap();
605 git_init(tmp.path());
606 setup_docker(tmp.path()).unwrap();
607 let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
608 assert!(dockerfile.exists());
609 let contents = std::fs::read_to_string(&dockerfile).unwrap();
610 assert!(contents.contains("FROM rust:1.82-slim"));
611 assert!(contents.contains("claude"));
612 assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
613 }
614
615 #[test]
616 fn setup_docker_idempotent() {
617 let tmp = TempDir::new().unwrap();
618 git_init(tmp.path());
619 setup_docker(tmp.path()).unwrap();
620 let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
621 setup_docker(tmp.path()).unwrap();
623 let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
624 assert_eq!(before, after);
625 }
626
627 #[test]
628 fn default_config_escapes_special_chars() {
629 let name = r#"my\"project"#;
630 let description = r#"desc with "quotes" and \backslash"#;
631 let branch = "main";
632 let config = default_config(name, description, branch, &[]);
633 toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
634 }
635
636 #[test]
637 fn write_local_toml_creates_file() {
638 let tmp = TempDir::new().unwrap();
639 write_local_toml(tmp.path(), "alice").unwrap();
640 let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
641 assert!(contents.contains("username = \"alice\""));
642 }
643
644 #[test]
645 fn write_local_toml_idempotent() {
646 let tmp = TempDir::new().unwrap();
647 write_local_toml(tmp.path(), "alice").unwrap();
648 let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
649 write_local_toml(tmp.path(), "bob").unwrap();
650 let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
651 assert_eq!(first, second);
652 assert!(second.contains("alice"));
653 }
654
655 #[test]
656 fn setup_non_tty_no_local_toml() {
657 let tmp = TempDir::new().unwrap();
658 git_init(tmp.path());
659 setup(tmp.path(), None, None, None).unwrap();
660 assert!(!tmp.path().join(".apm/local.toml").exists());
661 }
662
663 #[test]
664 fn default_config_with_collaborators() {
665 let config = default_config("proj", "desc", "main", &["alice"]);
666 let parsed: toml::Value = toml::from_str(&config).unwrap();
667 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
668 assert_eq!(collaborators.len(), 1);
669 assert_eq!(collaborators[0].as_str().unwrap(), "alice");
670 }
671
672 #[test]
673 fn default_config_empty_collaborators() {
674 let config = default_config("proj", "desc", "main", &[]);
675 let parsed: toml::Value = toml::from_str(&config).unwrap();
676 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
677 assert!(collaborators.is_empty());
678 }
679
680 #[test]
681 fn write_default_creates_new_file() {
682 let tmp = TempDir::new().unwrap();
683 let path = tmp.path().join("test.toml");
684 let mut msgs = Vec::new();
685 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
686 assert!(matches!(action, WriteAction::Created));
687 assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
688 }
689
690 #[test]
691 fn write_default_unchanged_when_identical() {
692 let tmp = TempDir::new().unwrap();
693 let path = tmp.path().join("test.toml");
694 std::fs::write(&path, "content").unwrap();
695 let mut msgs = Vec::new();
696 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
697 assert!(matches!(action, WriteAction::Unchanged));
698 }
699
700 #[test]
701 fn write_default_non_tty_writes_init_when_differs() {
702 let tmp = TempDir::new().unwrap();
705 let path = tmp.path().join("test.toml");
706 std::fs::write(&path, "modified").unwrap();
707 let mut msgs = Vec::new();
708 let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
709 assert!(matches!(action, WriteAction::InitWritten));
710 assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
711 assert_eq!(
712 std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
713 "default"
714 );
715 }
716
717 #[test]
718 fn init_path_for_preserves_extension() {
719 let p = std::path::Path::new("/a/b/workflow.toml");
720 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
721
722 let p = std::path::Path::new("/a/b/agents.md");
723 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
724 }
725
726 #[test]
727 fn setup_writes_init_files_when_content_differs() {
728 let tmp = TempDir::new().unwrap();
729 git_init(tmp.path());
730 setup(tmp.path(), None, None, None).unwrap();
732
733 let workflow = tmp.path().join(".apm/workflow.toml");
735 std::fs::write(&workflow, "# custom workflow\n").unwrap();
736
737 setup(tmp.path(), None, None, None).unwrap();
739 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
740 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
742 let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
744 assert_eq!(init_content, default_workflow_toml());
745 }
746
747 #[test]
748 fn setup_writes_config_init_when_modified() {
749 let tmp = TempDir::new().unwrap();
750 git_init(tmp.path());
751 setup(tmp.path(), None, None, None).unwrap();
752
753 let config_path = tmp.path().join(".apm/config.toml");
755 let mut content = std::fs::read_to_string(&config_path).unwrap();
756 content.push_str("\n[custom]\nfoo = \"bar\"\n");
757 std::fs::write(&config_path, &content).unwrap();
758
759 setup(tmp.path(), None, None, None).unwrap();
761 assert!(tmp.path().join(".apm/config.toml.init").exists());
762 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
764 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
766 assert!(!init_content.contains("[custom]"));
767 assert!(init_content.contains("[project]"));
768 assert!(init_content.contains("[workers]"));
769 assert!(init_content.contains("collaborators = []"));
771 }
772
773 #[test]
774 fn setup_no_false_diff_when_collaborators_present() {
775 let tmp = TempDir::new().unwrap();
776 git_init(tmp.path());
777 setup(tmp.path(), None, None, Some("alice")).unwrap();
779
780 setup(tmp.path(), None, None, None).unwrap();
782
783 assert!(!tmp.path().join(".apm/config.toml.init").exists());
785 }
786
787 #[test]
788 fn setup_config_init_collaborators_match_live() {
789 let tmp = TempDir::new().unwrap();
790 git_init(tmp.path());
791 setup(tmp.path(), None, None, Some("alice")).unwrap();
792
793 let config_path = tmp.path().join(".apm/config.toml");
795 let mut content = std::fs::read_to_string(&config_path).unwrap();
796 content.push_str("\n[custom]\nfoo = \"bar\"\n");
797 std::fs::write(&config_path, &content).unwrap();
798
799 setup(tmp.path(), None, None, None).unwrap();
800
801 assert!(tmp.path().join(".apm/config.toml.init").exists());
803 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
805 assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
806 }
807
808 #[test]
809 fn setup_no_false_diff_empty_collaborators() {
810 let tmp = TempDir::new().unwrap();
811 git_init(tmp.path());
812 setup(tmp.path(), None, None, None).unwrap();
814 setup(tmp.path(), None, None, None).unwrap();
816 assert!(!tmp.path().join(".apm/config.toml.init").exists());
817 }
818
819 #[test]
820 fn default_workflow_toml_is_valid() {
821 use crate::config::{SatisfiesDeps, WorkflowFile};
822
823 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
824 let states = &parsed.workflow.states;
825
826 let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
827 assert_eq!(
828 ids,
829 ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
830 );
831
832 for id in ["groomed", "ammend"] {
833 let s = states.iter().find(|s| s.id == id).unwrap();
834 assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
835 }
836
837 for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
838 let s = states.iter().find(|s| s.id == id).unwrap();
839 assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
840 }
841 }
842
843 #[test]
844 fn default_ticket_toml_is_valid() {
845 use crate::config::TicketFile;
846
847 let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
848 let sections = &parsed.ticket.sections;
849
850 for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
851 let s = sections.iter().find(|s| s.name == name).unwrap();
852 assert!(s.required, "section '{name}' should be required");
853 }
854 }
855
856 #[test]
857 fn default_config_has_in_repo_worktrees_dir() {
858 let config = default_config("myproj", "desc", "main", &[]);
859 assert!(
860 config.contains("dir = \"worktrees\""),
861 "default config should use in-repo worktrees dir: {config}"
862 );
863 assert!(
864 !config.contains("--worktrees"),
865 "default config must not reference the old external layout: {config}"
866 );
867 }
868
869 #[test]
870 fn setup_gitignore_includes_worktrees_pattern() {
871 let tmp = TempDir::new().unwrap();
872 git_init(tmp.path());
873 setup(tmp.path(), None, None, None).unwrap();
874 let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
875 assert!(contents.contains("/worktrees/"), ".gitignore must contain /worktrees/");
876 assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
877 }
878
879 #[test]
880 fn ensure_gitignore_worktrees_idempotent() {
881 let tmp = TempDir::new().unwrap();
882 let path = tmp.path().join(".gitignore");
883 let mut msgs = Vec::new();
884 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
885 let before = std::fs::read_to_string(&path).unwrap();
886 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
887 let after = std::fs::read_to_string(&path).unwrap();
888 assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
889 let count = before.matches("/worktrees/").count();
890 assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
891 }
892
893 #[test]
894 fn setup_creates_worktrees_dir_inside_repo() {
895 let tmp = TempDir::new().unwrap();
896 git_init(tmp.path());
897 setup(tmp.path(), None, None, None).unwrap();
898 assert!(
899 tmp.path().join("worktrees").exists(),
900 "worktrees dir should be created inside the repo"
901 );
902 }
903
904 #[test]
905 fn worktree_gitignore_pattern_simple() {
906 assert_eq!(
907 worktree_gitignore_pattern(std::path::Path::new("worktrees")),
908 Some("/worktrees/".to_string())
909 );
910 }
911
912 #[test]
913 fn worktree_gitignore_pattern_hidden_dir() {
914 assert_eq!(
915 worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
916 Some("/.apm--worktrees/".to_string())
917 );
918 }
919
920 #[test]
921 fn worktree_gitignore_pattern_nested() {
922 assert_eq!(
923 worktree_gitignore_pattern(std::path::Path::new("build/wt")),
924 Some("/build/wt/".to_string())
925 );
926 }
927
928 #[test]
929 fn worktree_gitignore_pattern_absolute_is_none() {
930 assert_eq!(
931 worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
932 None
933 );
934 }
935
936 #[test]
937 fn worktree_gitignore_pattern_parent_relative_is_none() {
938 assert_eq!(
939 worktree_gitignore_pattern(std::path::Path::new("../external")),
940 None
941 );
942 }
943}