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"];
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 = "../{name}--worktrees"
278agent_dirs = [".claude", ".cursor", ".windsurf"]
279
280[agents]
281max_concurrent = 3
282instructions = ".apm/agents.md"
283
284[workers]
285command = "claude"
286args = ["--print"]
287
288[worker_profiles.spec_agent]
289command = "claude"
290args = ["--print"]
291instructions = ".apm/apm.spec-writer.md"
292role_prefix = "You are a Spec-Writer agent assigned to ticket #<id>."
293
294[worker_profiles.impl_agent]
295command = "claude"
296args = ["--print"]
297instructions = ".apm/apm.worker.md"
298role_prefix = "You are a Worker agent assigned to ticket #<id>."
299
300[logging]
301enabled = false
302file = "{log_file}"
303"##
304 )
305}
306
307fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
308 let path = apm_dir.join("local.toml");
309 if !path.exists() {
310 let username_escaped = toml_escape(username);
311 std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
312 }
313 Ok(())
314}
315
316fn default_workflow_toml() -> &'static str {
317 include_str!("default/workflow.toml")
318}
319
320fn default_ticket_toml() -> &'static str {
321 include_str!("default/ticket.toml")
322}
323
324fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
325 if crate::git_util::has_commits(root) {
326 return Ok(());
327 }
328
329 crate::git_util::stage_files(root, &[
330 ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
331 ])?;
332
333 if crate::git_util::commit(root, "apm: initialize project").is_ok() {
334 messages.push("Created initial commit.".to_string());
335 }
336 Ok(())
337}
338
339fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
340 if let Ok(config) = crate::config::Config::load(root) {
341 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
342 let wt_dir = main_root.join(&config.worktrees.dir);
343 if !wt_dir.exists() {
344 std::fs::create_dir_all(&wt_dir)?;
345 messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
346 }
347 }
348 Ok(())
349}
350
351pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
352 let mut messages: Vec<String> = Vec::new();
353 let apm_dir = root.join(".apm");
354 std::fs::create_dir_all(&apm_dir)?;
355 let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
356 if dockerfile_path.exists() {
357 messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
358 return Ok(SetupDockerOutput { messages });
359 }
360 std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
361 messages.push("Created .apm/Dockerfile.apm-worker".to_string());
362 messages.push(String::new());
363 messages.push("Next steps:".to_string());
364 messages.push(" 1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
365 messages.push(" 2. Build the image:".to_string());
366 messages.push(" docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
367 messages.push(" 3. Add to .apm/config.toml:".to_string());
368 messages.push(" [workers]".to_string());
369 messages.push(" container = \"apm-worker\"".to_string());
370 messages.push(" 4. Configure credential lookup (optional, macOS only):".to_string());
371 messages.push(" [workers.keychain]".to_string());
372 messages.push(" ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
373 Ok(SetupDockerOutput { messages })
374}
375
376const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
377
378# System tools
379RUN apt-get update && apt-get install -y \
380 curl git unzip ca-certificates && \
381 rm -rf /var/lib/apt/lists/*
382
383# Claude CLI
384RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
385
386# apm binary (replace with your version or a downloaded release)
387COPY target/release/apm /usr/local/bin/apm
388
389# Add project-specific dependencies here:
390# RUN apt-get install -y nodejs npm # for Node projects
391# RUN pip install -r requirements.txt # for Python projects
392
393# gh CLI is NOT needed — the worker only runs local git commits;
394# push and PR creation happen on the host via apm state <id> implemented.
395
396WORKDIR /workspace
397"#;
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use std::process::Command;
403 use tempfile::TempDir;
404
405 fn git_init(dir: &Path) {
406 Command::new("git")
407 .args(["init", "-b", "main"])
408 .current_dir(dir)
409 .output()
410 .unwrap();
411 Command::new("git")
412 .args(["config", "user.email", "test@test.com"])
413 .current_dir(dir)
414 .output()
415 .unwrap();
416 Command::new("git")
417 .args(["config", "user.name", "Test"])
418 .current_dir(dir)
419 .output()
420 .unwrap();
421 }
422
423 #[test]
424 fn detect_default_branch_fresh_repo() {
425 let tmp = TempDir::new().unwrap();
426 git_init(tmp.path());
427 let branch = detect_default_branch(tmp.path());
428 assert_eq!(branch, "main");
429 }
430
431 #[test]
432 fn detect_default_branch_non_git() {
433 let tmp = TempDir::new().unwrap();
434 let branch = detect_default_branch(tmp.path());
435 assert_eq!(branch, "main");
436 }
437
438 #[test]
439 fn ensure_gitignore_creates_file() {
440 let tmp = TempDir::new().unwrap();
441 let path = tmp.path().join(".gitignore");
442 let mut msgs = Vec::new();
443 ensure_gitignore(&path, &mut msgs).unwrap();
444 let contents = std::fs::read_to_string(&path).unwrap();
445 assert!(contents.contains("tickets/NEXT_ID"));
446 assert!(contents.contains(".apm/local.toml"));
447 assert!(contents.contains(".apm/*.init"));
448 assert!(contents.contains(".apm/sessions.json"));
449 assert!(contents.contains(".apm/credentials.json"));
450 }
451
452 #[test]
453 fn ensure_gitignore_appends_missing_entry() {
454 let tmp = TempDir::new().unwrap();
455 let path = tmp.path().join(".gitignore");
456 std::fs::write(&path, "node_modules\n").unwrap();
457 let mut msgs = Vec::new();
458 ensure_gitignore(&path, &mut msgs).unwrap();
459 let contents = std::fs::read_to_string(&path).unwrap();
460 assert!(contents.contains("node_modules"));
461 assert!(contents.contains("tickets/NEXT_ID"));
462 }
463
464 #[test]
465 fn ensure_gitignore_idempotent() {
466 let tmp = TempDir::new().unwrap();
467 let path = tmp.path().join(".gitignore");
468 let mut msgs = Vec::new();
469 ensure_gitignore(&path, &mut msgs).unwrap();
470 let before = std::fs::read_to_string(&path).unwrap();
471 ensure_gitignore(&path, &mut msgs).unwrap();
472 let after = std::fs::read_to_string(&path).unwrap();
473 assert_eq!(before, after);
474 }
475
476 #[test]
477 fn setup_creates_expected_files() {
478 let tmp = TempDir::new().unwrap();
479 git_init(tmp.path());
480 setup(tmp.path(), None, None, None).unwrap();
481
482 assert!(tmp.path().join("tickets").exists());
483 assert!(tmp.path().join(".apm/config.toml").exists());
484 assert!(tmp.path().join(".apm/workflow.toml").exists());
485 assert!(tmp.path().join(".apm/ticket.toml").exists());
486 assert!(tmp.path().join(".apm/agents.md").exists());
487 assert!(tmp.path().join(".apm/apm.spec-writer.md").exists());
488 assert!(tmp.path().join(".apm/apm.worker.md").exists());
489 assert!(tmp.path().join(".gitignore").exists());
490 assert!(tmp.path().join("CLAUDE.md").exists());
491 }
492
493 #[test]
494 fn setup_non_tty_uses_dir_name_and_empty_description() {
495 let tmp = TempDir::new().unwrap();
496 git_init(tmp.path());
497 setup(tmp.path(), None, None, None).unwrap();
498
499 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
500 let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
501 assert!(config.contains(&format!("name = \"{dir_name}\"")));
502 assert!(config.contains("description = \"\""));
503 }
504
505 #[test]
506 fn setup_is_idempotent() {
507 let tmp = TempDir::new().unwrap();
508 git_init(tmp.path());
509 setup(tmp.path(), None, None, None).unwrap();
510
511 let config_path = tmp.path().join(".apm/config.toml");
513 let original = std::fs::read_to_string(&config_path).unwrap();
514
515 setup(tmp.path(), None, None, None).unwrap();
516 let after = std::fs::read_to_string(&config_path).unwrap();
517 assert_eq!(original, after);
518 }
519
520 #[test]
521 fn migrate_moves_files_and_updates_claude_md() {
522 let tmp = TempDir::new().unwrap();
523 git_init(tmp.path());
524
525 std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
526 std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
527 std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
528
529 migrate(tmp.path()).unwrap();
530
531 assert!(tmp.path().join(".apm/config.toml").exists());
532 assert!(tmp.path().join(".apm/agents.md").exists());
533 assert!(!tmp.path().join("apm.toml").exists());
534 assert!(!tmp.path().join("apm.agents.md").exists());
535
536 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
537 assert!(claude.contains("@.apm/agents.md"));
538 assert!(!claude.contains("@apm.agents.md"));
539 }
540
541 #[test]
542 fn migrate_already_migrated() {
543 let tmp = TempDir::new().unwrap();
544 git_init(tmp.path());
545 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
546 std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
547
548 migrate(tmp.path()).unwrap();
550 }
551
552 #[test]
553 fn setup_docker_creates_dockerfile() {
554 let tmp = TempDir::new().unwrap();
555 git_init(tmp.path());
556 setup_docker(tmp.path()).unwrap();
557 let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
558 assert!(dockerfile.exists());
559 let contents = std::fs::read_to_string(&dockerfile).unwrap();
560 assert!(contents.contains("FROM rust:1.82-slim"));
561 assert!(contents.contains("claude"));
562 assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
563 }
564
565 #[test]
566 fn setup_docker_idempotent() {
567 let tmp = TempDir::new().unwrap();
568 git_init(tmp.path());
569 setup_docker(tmp.path()).unwrap();
570 let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
571 setup_docker(tmp.path()).unwrap();
573 let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
574 assert_eq!(before, after);
575 }
576
577 #[test]
578 fn default_config_escapes_special_chars() {
579 let name = r#"my\"project"#;
580 let description = r#"desc with "quotes" and \backslash"#;
581 let branch = "main";
582 let config = default_config(name, description, branch, &[]);
583 toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
584 }
585
586 #[test]
587 fn write_local_toml_creates_file() {
588 let tmp = TempDir::new().unwrap();
589 write_local_toml(tmp.path(), "alice").unwrap();
590 let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
591 assert!(contents.contains("username = \"alice\""));
592 }
593
594 #[test]
595 fn write_local_toml_idempotent() {
596 let tmp = TempDir::new().unwrap();
597 write_local_toml(tmp.path(), "alice").unwrap();
598 let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
599 write_local_toml(tmp.path(), "bob").unwrap();
600 let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
601 assert_eq!(first, second);
602 assert!(second.contains("alice"));
603 }
604
605 #[test]
606 fn setup_non_tty_no_local_toml() {
607 let tmp = TempDir::new().unwrap();
608 git_init(tmp.path());
609 setup(tmp.path(), None, None, None).unwrap();
610 assert!(!tmp.path().join(".apm/local.toml").exists());
611 }
612
613 #[test]
614 fn default_config_with_collaborators() {
615 let config = default_config("proj", "desc", "main", &["alice"]);
616 let parsed: toml::Value = toml::from_str(&config).unwrap();
617 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
618 assert_eq!(collaborators.len(), 1);
619 assert_eq!(collaborators[0].as_str().unwrap(), "alice");
620 }
621
622 #[test]
623 fn default_config_empty_collaborators() {
624 let config = default_config("proj", "desc", "main", &[]);
625 let parsed: toml::Value = toml::from_str(&config).unwrap();
626 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
627 assert!(collaborators.is_empty());
628 }
629
630 #[test]
631 fn write_default_creates_new_file() {
632 let tmp = TempDir::new().unwrap();
633 let path = tmp.path().join("test.toml");
634 let mut msgs = Vec::new();
635 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
636 assert!(matches!(action, WriteAction::Created));
637 assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
638 }
639
640 #[test]
641 fn write_default_unchanged_when_identical() {
642 let tmp = TempDir::new().unwrap();
643 let path = tmp.path().join("test.toml");
644 std::fs::write(&path, "content").unwrap();
645 let mut msgs = Vec::new();
646 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
647 assert!(matches!(action, WriteAction::Unchanged));
648 }
649
650 #[test]
651 fn write_default_non_tty_writes_init_when_differs() {
652 let tmp = TempDir::new().unwrap();
655 let path = tmp.path().join("test.toml");
656 std::fs::write(&path, "modified").unwrap();
657 let mut msgs = Vec::new();
658 let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
659 assert!(matches!(action, WriteAction::InitWritten));
660 assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
661 assert_eq!(
662 std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
663 "default"
664 );
665 }
666
667 #[test]
668 fn init_path_for_preserves_extension() {
669 let p = std::path::Path::new("/a/b/workflow.toml");
670 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
671
672 let p = std::path::Path::new("/a/b/agents.md");
673 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
674 }
675
676 #[test]
677 fn setup_writes_init_files_when_content_differs() {
678 let tmp = TempDir::new().unwrap();
679 git_init(tmp.path());
680 setup(tmp.path(), None, None, None).unwrap();
682
683 let workflow = tmp.path().join(".apm/workflow.toml");
685 std::fs::write(&workflow, "# custom workflow\n").unwrap();
686
687 setup(tmp.path(), None, None, None).unwrap();
689 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
690 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
692 let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
694 assert_eq!(init_content, default_workflow_toml());
695 }
696
697 #[test]
698 fn setup_writes_config_init_when_modified() {
699 let tmp = TempDir::new().unwrap();
700 git_init(tmp.path());
701 setup(tmp.path(), None, None, None).unwrap();
702
703 let config_path = tmp.path().join(".apm/config.toml");
705 let mut content = std::fs::read_to_string(&config_path).unwrap();
706 content.push_str("\n[custom]\nfoo = \"bar\"\n");
707 std::fs::write(&config_path, &content).unwrap();
708
709 setup(tmp.path(), None, None, None).unwrap();
711 assert!(tmp.path().join(".apm/config.toml.init").exists());
712 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
714 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
716 assert!(!init_content.contains("[custom]"));
717 assert!(init_content.contains("[project]"));
718 assert!(init_content.contains("[workers]"));
719 assert!(init_content.contains("collaborators = []"));
721 }
722
723 #[test]
724 fn setup_no_false_diff_when_collaborators_present() {
725 let tmp = TempDir::new().unwrap();
726 git_init(tmp.path());
727 setup(tmp.path(), None, None, Some("alice")).unwrap();
729
730 setup(tmp.path(), None, None, None).unwrap();
732
733 assert!(!tmp.path().join(".apm/config.toml.init").exists());
735 }
736
737 #[test]
738 fn setup_config_init_collaborators_match_live() {
739 let tmp = TempDir::new().unwrap();
740 git_init(tmp.path());
741 setup(tmp.path(), None, None, Some("alice")).unwrap();
742
743 let config_path = tmp.path().join(".apm/config.toml");
745 let mut content = std::fs::read_to_string(&config_path).unwrap();
746 content.push_str("\n[custom]\nfoo = \"bar\"\n");
747 std::fs::write(&config_path, &content).unwrap();
748
749 setup(tmp.path(), None, None, None).unwrap();
750
751 assert!(tmp.path().join(".apm/config.toml.init").exists());
753 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
755 assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
756 }
757
758 #[test]
759 fn setup_no_false_diff_empty_collaborators() {
760 let tmp = TempDir::new().unwrap();
761 git_init(tmp.path());
762 setup(tmp.path(), None, None, None).unwrap();
764 setup(tmp.path(), None, None, None).unwrap();
766 assert!(!tmp.path().join(".apm/config.toml.init").exists());
767 }
768
769 #[test]
770 fn default_workflow_toml_is_valid() {
771 use crate::config::{SatisfiesDeps, WorkflowFile};
772
773 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
774 let states = &parsed.workflow.states;
775
776 let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
777 assert_eq!(
778 ids,
779 ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
780 );
781
782 for id in ["groomed", "ammend"] {
783 let s = states.iter().find(|s| s.id == id).unwrap();
784 assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
785 }
786
787 for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
788 let s = states.iter().find(|s| s.id == id).unwrap();
789 assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
790 }
791 }
792
793 #[test]
794 fn default_ticket_toml_is_valid() {
795 use crate::config::TicketFile;
796
797 let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
798 let sections = &parsed.ticket.sections;
799
800 for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
801 let s = sections.iter().find(|s| s.name == name).unwrap();
802 assert!(s.required, "section '{name}' should be required");
803 }
804 }
805}