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