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