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 write_default(
59 &tickets_dir.join("EPIC.md"),
60 EPIC_MD_PLACEHOLDER,
61 "tickets/EPIC.md",
62 &mut messages,
63 )?;
64
65 let apm_dir = root.join(".apm");
66 std::fs::create_dir_all(&apm_dir)?;
67
68 let local_toml = apm_dir.join("local.toml");
69
70 let has_git_host = {
72 let config_path = apm_dir.join("config.toml");
73 config_path.exists() && crate::config::Config::load(root)
74 .map(|cfg| cfg.git_host.provider.is_some())
75 .unwrap_or(false)
76 };
77
78 if !has_git_host && !local_toml.exists() {
80 if let Some(u) = username {
81 if !u.is_empty() {
82 write_local_toml(&apm_dir, u)?;
83 messages.push("Created .apm/local.toml".to_string());
84 }
85 }
86 }
87
88 let effective_username = username.unwrap_or("");
89 let config_path = apm_dir.join("config.toml");
90 if !config_path.exists() {
91 let default_name = name.unwrap_or_else(|| {
92 root.file_name()
93 .and_then(|n| n.to_str())
94 .unwrap_or("project")
95 });
96 let effective_description = description.unwrap_or("");
97 let collaborators: Vec<&str> = if effective_username.is_empty() {
98 vec![]
99 } else {
100 vec![effective_username]
101 };
102 let branch = detect_default_branch(root);
103 std::fs::write(&config_path, default_config(default_name, effective_description, &branch, &collaborators))?;
104 messages.push("Created .apm/config.toml".to_string());
105 } else {
106 let existing = std::fs::read_to_string(&config_path)?;
109 if let Ok(val) = existing.parse::<toml::Value>() {
110 let n = val.get("project")
111 .and_then(|p| p.get("name"))
112 .and_then(|v| v.as_str())
113 .unwrap_or("project");
114 let d = val.get("project")
115 .and_then(|p| p.get("description"))
116 .and_then(|v| v.as_str())
117 .unwrap_or("");
118 let b = val.get("project")
119 .and_then(|p| p.get("default_branch"))
120 .and_then(|v| v.as_str())
121 .unwrap_or("main");
122 let collab_owned: Vec<String> = val
123 .get("project")
124 .and_then(|p| p.get("collaborators"))
125 .and_then(|v| v.as_array())
126 .map(|arr| {
127 arr.iter()
128 .filter_map(|v| v.as_str().map(|s| s.to_owned()))
129 .collect()
130 })
131 .unwrap_or_default();
132 let collabs: Vec<&str> = collab_owned.iter().map(|s| s.as_str()).collect();
133 write_default(&config_path, &default_config(n, d, b, &collabs), ".apm/config.toml", &mut messages)?;
134 }
135 }
136 write_default(&apm_dir.join("workflow.toml"), default_workflow_toml(), ".apm/workflow.toml", &mut messages)?;
137 write_default(&apm_dir.join("ticket.toml"), default_ticket_toml(), ".apm/ticket.toml", &mut messages)?;
138 migrate_flat_agent_files(root, &apm_dir, &mut messages)?;
139 migrate_project_md_location(root, &apm_dir, &mut messages)?;
140 let agents_default_dir = apm_dir.join("agents/default");
141 std::fs::create_dir_all(&agents_default_dir)
142 .map_err(|e| anyhow::anyhow!("cannot create {}: {e}", agents_default_dir.display()))?;
143 write_default(&agents_default_dir.join("apm.spec-writer.md"), include_str!("default/agents/default/apm.spec-writer.md"), ".apm/agents/default/apm.spec-writer.md", &mut messages)?;
144 write_default(&agents_default_dir.join("apm.worker.md"), include_str!("default/agents/default/apm.worker.md"), ".apm/agents/default/apm.worker.md", &mut messages)?;
145 write_default(
146 &apm_dir.join("project.md"),
147 include_str!("default/project.md"),
148 ".apm/project.md",
149 &mut messages,
150 )?;
151 write_default(
152 &agents_default_dir.join("apm.main-agent.md"),
153 include_str!("default/agents/default/apm.main-agent.md"),
154 ".apm/agents/default/apm.main-agent.md",
155 &mut messages,
156 )?;
157 ensure_claude_md(root, &[
158 ".apm/project.md",
159 ".apm/agents/default/apm.main-agent.md",
160 ], &mut messages)?;
161 let gitignore = root.join(".gitignore");
162 let wt_pattern = crate::config::Config::load(root)
163 .ok()
164 .and_then(|c| worktree_gitignore_pattern(&c.worktrees.dir));
165 ensure_gitignore(&gitignore, wt_pattern.as_deref(), &mut messages)?;
166 ensure_gitattributes(&root.join(".gitattributes"), &mut messages)?;
167 maybe_initial_commit(root, &mut messages)?;
168 ensure_worktrees_dir(root, &mut messages)?;
169 Ok(SetupOutput { messages })
170}
171
172fn migrate_flat_agent_files(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
176 let agents_default_dir = apm_dir.join("agents/default");
177 std::fs::create_dir_all(&agents_default_dir)?;
178
179 let moves = [
180 ("agents.md", "agents.md"),
181 ("apm.spec-writer.md", "apm.spec-writer.md"),
182 ("apm.worker.md", "apm.worker.md"),
183 ("style.md", "style.md"),
184 ];
185 for (old_name, new_name) in &moves {
186 let old_path = apm_dir.join(old_name);
187 let new_path = agents_default_dir.join(new_name);
188 if old_path.exists() && !new_path.exists() {
189 std::fs::rename(&old_path, &new_path)?;
190 messages.push(format!("Moved .apm/{old_name} → .apm/agents/default/{new_name}"));
191 }
192 }
193
194 let path_rewrites: &[(&str, &str)] = &[
195 ("@.apm/agents.md", "@.apm/agents/default/agents.md"),
196 ("@.apm/style.md", "@.apm/agents/default/style.md"),
197 ("@.apm/agents/default/agents.md", "@.apm/project.md\n@.apm/agents/default/apm.main-agent.md"),
198 ];
199 let claude_path = root.join("CLAUDE.md");
200 if claude_path.exists() {
201 let contents = std::fs::read_to_string(&claude_path)?;
202 let mut updated = contents.clone();
203 for (old, new) in path_rewrites {
204 updated = updated.replace(old, new);
205 }
206 if updated != contents {
207 std::fs::write(&claude_path, &updated)?;
208 messages.push("Updated CLAUDE.md (agent file paths → agents/default/)".to_string());
209 }
210 }
211
212 let instructions_rewrites: &[(&str, &str)] = &[
213 (".apm/agents.md", ".apm/agents/default/agents.md"),
214 (".apm/apm.spec-writer.md", ".apm/agents/default/apm.spec-writer.md"),
215 (".apm/apm.worker.md", ".apm/agents/default/apm.worker.md"),
216 (".apm/style.md", ".apm/agents/default/style.md"),
217 ("instructions = \".apm/agents/default/agents.md\"", "project = \".apm/project.md\""),
218 ];
219
220 let config_path = apm_dir.join("config.toml");
221 if config_path.exists() {
222 let contents = std::fs::read_to_string(&config_path)?;
223 let mut updated = contents.clone();
224 for (old, new) in instructions_rewrites {
225 updated = updated.replace(old, new);
226 }
227 if updated != contents {
228 std::fs::write(&config_path, &updated)?;
229 messages.push("Updated .apm/config.toml (instructions paths → agents/default/)".to_string());
230 }
231 }
232
233 let workflow_path = apm_dir.join("workflow.toml");
234 if workflow_path.exists() {
235 let contents = std::fs::read_to_string(&workflow_path)?;
236 let mut updated = contents.clone();
237 for (old, new) in instructions_rewrites {
238 updated = updated.replace(old, new);
239 }
240 if updated != contents {
241 std::fs::write(&workflow_path, &updated)?;
242 messages.push("Updated .apm/workflow.toml (instructions paths → agents/default/)".to_string());
243 }
244 }
245
246 Ok(())
247}
248
249fn migrate_project_md_location(root: &Path, apm_dir: &Path, messages: &mut Vec<String>) -> Result<()> {
250 let old_path = apm_dir.join("agents/default/apm.project.md");
251 let new_path = apm_dir.join("project.md");
252 if old_path.exists() && !new_path.exists() {
253 std::fs::rename(&old_path, &new_path)?;
254 messages.push("Moved .apm/agents/default/apm.project.md → .apm/project.md".to_string());
255 }
256 let claude_path = root.join("CLAUDE.md");
257 if claude_path.exists() {
258 let contents = std::fs::read_to_string(&claude_path)?;
259 let updated = contents.replace("@.apm/agents/default/apm.project.md", "@.apm/project.md");
260 if updated != contents {
261 std::fs::write(&claude_path, &updated)?;
262 messages.push("Updated CLAUDE.md (project → .apm/project.md)".to_string());
263 }
264 }
265 let config_path = apm_dir.join("config.toml");
266 if config_path.exists() {
267 let contents = std::fs::read_to_string(&config_path)?;
268 let updated = contents.replace(
269 "project = \".apm/agents/default/apm.project.md\"",
270 "project = \".apm/project.md\"",
271 );
272 if updated != contents {
273 std::fs::write(&config_path, &updated)?;
274 messages.push("Updated config.toml (project → .apm/project.md)".to_string());
275 }
276 }
277 Ok(())
278}
279
280pub fn migrate(root: &Path) -> Result<Vec<String>> {
281 let mut messages: Vec<String> = Vec::new();
282 let apm_dir = root.join(".apm");
283 let new_config = apm_dir.join("config.toml");
284
285 if new_config.exists() {
286 messages.push("Already migrated.".to_string());
287 return Ok(messages);
288 }
289
290 let old_config = root.join("apm.toml");
291 let old_agents = root.join("apm.agents.md");
292
293 if !old_config.exists() && !old_agents.exists() {
294 messages.push("Nothing to migrate.".to_string());
295 return Ok(messages);
296 }
297
298 std::fs::create_dir_all(&apm_dir)?;
299
300 if old_config.exists() {
301 std::fs::rename(&old_config, &new_config)?;
302 messages.push("Moved apm.toml → .apm/config.toml".to_string());
303 }
304
305 if old_agents.exists() {
306 let new_agents = apm_dir.join("agents.md");
307 std::fs::rename(&old_agents, &new_agents)?;
308 messages.push("Moved apm.agents.md → .apm/agents.md".to_string());
309 }
310
311 let claude_path = root.join("CLAUDE.md");
312 if claude_path.exists() {
313 let contents = std::fs::read_to_string(&claude_path)?;
314 if contents.contains("@apm.agents.md") {
315 let updated = contents.replace("@apm.agents.md", "@.apm/agents.md");
316 std::fs::write(&claude_path, updated)?;
317 messages.push("Updated CLAUDE.md (@apm.agents.md → @.apm/agents.md)".to_string());
318 }
319 }
320
321 Ok(messages)
322}
323
324pub fn detect_default_branch(root: &Path) -> String {
325 crate::git_util::current_branch(root)
326 .ok()
327 .filter(|s| !s.is_empty())
328 .unwrap_or_else(|| "main".to_string())
329}
330
331pub fn worktree_gitignore_pattern(dir: &Path) -> Option<String> {
336 let s = dir.to_string_lossy();
337 if s.starts_with('/') || s.starts_with("..") {
338 return None;
339 }
340 Some(format!("/{s}/"))
341}
342
343pub fn ensure_gitignore(path: &Path, worktree_pattern: Option<&str>, messages: &mut Vec<String>) -> Result<()> {
344 let static_entries = [".apm/local.toml", ".apm/epics.toml", ".apm/*.init", ".apm/sessions.json", ".apm/credentials.json"];
345 let mut entries: Vec<&str> = static_entries.to_vec();
346 let owned_pattern;
347 if let Some(p) = worktree_pattern {
348 entries.push("# apm worktrees");
349 owned_pattern = p.to_owned();
350 entries.push(&owned_pattern);
351 }
352 if path.exists() {
353 let mut contents = std::fs::read_to_string(path)?;
354 let mut changed = false;
355 for entry in &entries {
356 if !contents.contains(entry) {
357 if !contents.ends_with('\n') {
358 contents.push('\n');
359 }
360 contents.push_str(entry);
361 contents.push('\n');
362 changed = true;
363 }
364 }
365 if changed {
366 std::fs::write(path, &contents)?;
367 messages.push("Updated .gitignore".to_string());
368 }
369 } else {
370 std::fs::write(path, entries.join("\n") + "\n")?;
371 messages.push("Created .gitignore".to_string());
372 }
373 Ok(())
374}
375
376pub fn ensure_gitattributes(path: &Path, messages: &mut Vec<String>) -> Result<()> {
377 let entry = "tickets/EPIC.md merge=ours";
378 if path.exists() {
379 let mut contents = std::fs::read_to_string(path)?;
380 if !contents.contains(entry) {
381 if !contents.ends_with('\n') {
382 contents.push('\n');
383 }
384 contents.push_str(entry);
385 contents.push('\n');
386 std::fs::write(path, &contents)?;
387 messages.push("Updated .gitattributes".to_string());
388 }
389 } else {
390 std::fs::write(path, format!("{entry}\n"))?;
391 messages.push("Created .gitattributes".to_string());
392 }
393 Ok(())
394}
395
396fn ensure_claude_md(root: &Path, agents_paths: &[&str], messages: &mut Vec<String>) -> Result<()> {
397 let claude_path = root.join("CLAUDE.md");
398 if claude_path.exists() {
399 let contents = std::fs::read_to_string(&claude_path)?;
400 let absent: Vec<&str> = agents_paths
401 .iter()
402 .filter(|p| !contents.contains(&format!("@{p}")))
403 .copied()
404 .collect();
405 if absent.is_empty() {
406 return Ok(());
407 }
408 let prefix: String = absent.iter().map(|p| format!("@{p}\n")).collect();
409 let separator = if contents.is_empty() { "" } else { "\n" };
410 std::fs::write(&claude_path, format!("{prefix}{separator}{contents}"))?;
411 messages.push(format!(
412 "Updated CLAUDE.md (added {} import).",
413 absent.iter().map(|p| format!("@{p}")).collect::<Vec<_>>().join(", ")
414 ));
415 } else {
416 let content: String = agents_paths.iter().map(|p| format!("@{p}\n")).collect();
417 std::fs::write(&claude_path, content)?;
418 messages.push("Created CLAUDE.md.".to_string());
419 }
420 Ok(())
421}
422
423const EPIC_MD_PLACEHOLDER: &str = "\
424# EPIC.md — per-epic context document
425
426Each epic branch writes its own `tickets/EPIC.md` with the epic title and any
427notes added by the supervisor. APM reads it from the epic branch to build the
428context bundle injected into worker prompts for tickets that belong to that epic.
429
430The copy on `main` is this placeholder and is never read by APM at runtime.
431The `tickets/EPIC.md merge=ours` rule in `.gitattributes` prevents merge
432conflicts when multiple epics are open simultaneously.
433";
434
435#[cfg(target_os = "macos")]
436fn default_log_file(name: &str) -> String {
437 format!("~/Library/Logs/apm/{name}.log")
438}
439
440#[cfg(not(target_os = "macos"))]
441fn default_log_file(name: &str) -> String {
442 format!("~/.local/state/apm/{name}.log")
443}
444
445fn toml_escape(s: &str) -> String {
446 s.replace('\\', "\\\\").replace('"', "\\\"")
447}
448
449fn default_config(name: &str, description: &str, default_branch: &str, collaborators: &[&str]) -> String {
450 let log_file = default_log_file(name);
451 let name = toml_escape(name);
452 let description = toml_escape(description);
453 let default_branch = toml_escape(default_branch);
454 let log_file = toml_escape(&log_file);
455 let collaborators_line = {
456 let items: Vec<String> = collaborators.iter().map(|u| format!("\"{}\"", toml_escape(u))).collect();
457 format!("collaborators = [{}]", items.join(", "))
458 };
459 format!(
460 r##"[project]
461name = "{name}"
462description = "{description}"
463default_branch = "{default_branch}"
464{collaborators_line}
465
466[tickets]
467dir = "tickets"
468archive_dir = "archive/tickets"
469
470[worktrees]
471dir = ".apm--worktrees"
472agent_dirs = [".claude", ".cursor", ".windsurf"]
473
474[agents]
475max_concurrent = 3
476max_workers_per_epic = 1
477max_workers_on_default = 1
478project = ".apm/project.md"
479
480[workers]
481agent = "claude"
482
483[workers.options]
484model = "sonnet"
485
486[worker_profiles.spec_agent]
487role = "spec-writer"
488
489[logging]
490enabled = false
491file = "{log_file}"
492"##
493 )
494}
495
496fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
497 let path = apm_dir.join("local.toml");
498 if !path.exists() {
499 let username_escaped = toml_escape(username);
500 std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
501 }
502 Ok(())
503}
504
505pub fn default_workflow_toml() -> &'static str {
506 include_str!("default/workflow.toml")
507}
508
509pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
512 #[derive(serde::Deserialize)]
513 struct Wrapper {
514 workflow: crate::config::WorkflowConfig,
515 }
516 let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
517 .expect("default workflow.toml is valid TOML");
518 let mut map = std::collections::HashMap::new();
519 for state in &w.workflow.states {
520 for tr in &state.transitions {
521 if matches!(
522 tr.completion,
523 crate::config::CompletionStrategy::Merge
524 | crate::config::CompletionStrategy::PrOrEpicMerge
525 ) {
526 if let Some(ref of) = tr.on_failure {
527 map.insert(tr.to.clone(), of.clone());
528 }
529 }
530 }
531 }
532 map
533}
534
535fn default_ticket_toml() -> &'static str {
536 include_str!("default/ticket.toml")
537}
538
539fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
540 if crate::git_util::has_commits(root) {
541 return Ok(());
542 }
543
544 crate::git_util::stage_files(root, &[
545 ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
546 ])?;
547
548 if crate::git_util::commit(root, "apm: initialize project").is_ok() {
549 messages.push("Created initial commit.".to_string());
550 }
551 Ok(())
552}
553
554fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
555 if let Ok(config) = crate::config::Config::load(root) {
556 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
557 let wt_dir = main_root.join(&config.worktrees.dir);
558 if !wt_dir.exists() {
559 std::fs::create_dir_all(&wt_dir)?;
560 messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
561 }
562 }
563 Ok(())
564}
565
566pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
567 let mut messages: Vec<String> = Vec::new();
568 let apm_dir = root.join(".apm");
569 std::fs::create_dir_all(&apm_dir)?;
570 let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
571 if dockerfile_path.exists() {
572 messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
573 return Ok(SetupDockerOutput { messages });
574 }
575 std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
576 messages.push("Created .apm/Dockerfile.apm-worker".to_string());
577 messages.push(String::new());
578 messages.push("Next steps:".to_string());
579 messages.push(" 1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
580 messages.push(" 2. Build the image:".to_string());
581 messages.push(" docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
582 messages.push(" 3. Add to .apm/config.toml:".to_string());
583 messages.push(" [workers]".to_string());
584 messages.push(" container = \"apm-worker\"".to_string());
585 messages.push(" 4. Configure credential lookup (optional, macOS only):".to_string());
586 messages.push(" [workers.keychain]".to_string());
587 messages.push(" ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
588 Ok(SetupDockerOutput { messages })
589}
590
591const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
592
593# System tools
594RUN apt-get update && apt-get install -y \
595 curl git unzip ca-certificates && \
596 rm -rf /var/lib/apt/lists/*
597
598# Claude CLI
599RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
600
601# apm binary (replace with your version or a downloaded release)
602COPY target/release/apm /usr/local/bin/apm
603
604# Add project-specific dependencies here:
605# RUN apt-get install -y nodejs npm # for Node projects
606# RUN pip install -r requirements.txt # for Python projects
607
608# gh CLI is NOT needed — the worker only runs local git commits;
609# push and PR creation happen on the host via apm state <id> implemented.
610
611WORKDIR /workspace
612"#;
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use std::process::Command;
618 use tempfile::TempDir;
619
620 fn git_init(dir: &Path) {
621 Command::new("git")
622 .args(["init", "-b", "main"])
623 .current_dir(dir)
624 .output()
625 .unwrap();
626 Command::new("git")
627 .args(["config", "user.email", "test@test.com"])
628 .current_dir(dir)
629 .output()
630 .unwrap();
631 Command::new("git")
632 .args(["config", "user.name", "Test"])
633 .current_dir(dir)
634 .output()
635 .unwrap();
636 }
637
638 #[test]
639 fn detect_default_branch_fresh_repo() {
640 let tmp = TempDir::new().unwrap();
641 git_init(tmp.path());
642 let branch = detect_default_branch(tmp.path());
643 assert_eq!(branch, "main");
644 }
645
646 #[test]
647 fn detect_default_branch_non_git() {
648 let tmp = TempDir::new().unwrap();
649 let branch = detect_default_branch(tmp.path());
650 assert_eq!(branch, "main");
651 }
652
653 #[test]
654 fn ensure_gitignore_creates_file() {
655 let tmp = TempDir::new().unwrap();
656 let path = tmp.path().join(".gitignore");
657 let mut msgs = Vec::new();
658 ensure_gitignore(&path, None, &mut msgs).unwrap();
659 let contents = std::fs::read_to_string(&path).unwrap();
660 assert!(contents.contains(".apm/local.toml"));
661 assert!(contents.contains(".apm/*.init"));
662 assert!(contents.contains(".apm/sessions.json"));
663 assert!(contents.contains(".apm/credentials.json"));
664 }
665
666 #[test]
667 fn ensure_gitignore_appends_missing_entry() {
668 let tmp = TempDir::new().unwrap();
669 let path = tmp.path().join(".gitignore");
670 std::fs::write(&path, "node_modules\n").unwrap();
671 let mut msgs = Vec::new();
672 ensure_gitignore(&path, None, &mut msgs).unwrap();
673 let contents = std::fs::read_to_string(&path).unwrap();
674 assert!(contents.contains("node_modules"));
675 assert!(contents.contains(".apm/local.toml"));
676 }
677
678 #[test]
679 fn ensure_gitignore_idempotent() {
680 let tmp = TempDir::new().unwrap();
681 let path = tmp.path().join(".gitignore");
682 let mut msgs = Vec::new();
683 ensure_gitignore(&path, None, &mut msgs).unwrap();
684 let before = std::fs::read_to_string(&path).unwrap();
685 ensure_gitignore(&path, None, &mut msgs).unwrap();
686 let after = std::fs::read_to_string(&path).unwrap();
687 assert_eq!(before, after);
688 }
689
690 #[test]
691 fn setup_creates_expected_files() {
692 let tmp = TempDir::new().unwrap();
693 git_init(tmp.path());
694 setup(tmp.path(), None, None, None).unwrap();
695
696 assert!(tmp.path().join("tickets").exists());
697 assert!(tmp.path().join(".apm/config.toml").exists());
698 assert!(tmp.path().join(".apm/workflow.toml").exists());
699 assert!(tmp.path().join(".apm/ticket.toml").exists());
700 assert!(!tmp.path().join(".apm/agents/default/agents.md").exists());
701 assert!(tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
702 assert!(tmp.path().join(".apm/agents/default/apm.worker.md").exists());
703 assert!(tmp.path().join(".apm/project.md").exists());
704 assert!(tmp.path().join(".apm/agents/default/apm.main-agent.md").exists());
705 assert!(!tmp.path().join(".apm/agents.md").exists());
706 assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
707 assert!(!tmp.path().join(".apm/apm.worker.md").exists());
708 assert!(!tmp.path().join(".apm/agents/claude/apm.spec-writer.md").exists());
709 assert!(!tmp.path().join(".apm/agents/claude/apm.worker.md").exists());
710 assert!(tmp.path().join(".gitignore").exists());
711 assert!(tmp.path().join("CLAUDE.md").exists());
712 }
713
714 #[test]
715 fn setup_non_tty_uses_dir_name_and_empty_description() {
716 let tmp = TempDir::new().unwrap();
717 git_init(tmp.path());
718 setup(tmp.path(), None, None, None).unwrap();
719
720 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
721 let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
722 assert!(config.contains(&format!("name = \"{dir_name}\"")));
723 assert!(config.contains("description = \"\""));
724 }
725
726 #[test]
727 fn setup_is_idempotent() {
728 let tmp = TempDir::new().unwrap();
729 git_init(tmp.path());
730 setup(tmp.path(), None, None, None).unwrap();
731
732 let config_path = tmp.path().join(".apm/config.toml");
734 let original = std::fs::read_to_string(&config_path).unwrap();
735
736 setup(tmp.path(), None, None, None).unwrap();
737 let after = std::fs::read_to_string(&config_path).unwrap();
738 assert_eq!(original, after);
739 }
740
741 #[test]
742 fn migrate_moves_files_and_updates_claude_md() {
743 let tmp = TempDir::new().unwrap();
744 git_init(tmp.path());
745
746 std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
747 std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
748 std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
749
750 migrate(tmp.path()).unwrap();
751
752 assert!(tmp.path().join(".apm/config.toml").exists());
753 assert!(tmp.path().join(".apm/agents.md").exists());
754 assert!(!tmp.path().join("apm.toml").exists());
755 assert!(!tmp.path().join("apm.agents.md").exists());
756
757 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
758 assert!(claude.contains("@.apm/agents.md"));
759 assert!(!claude.contains("@apm.agents.md"));
760 }
761
762 #[test]
763 fn migrate_already_migrated() {
764 let tmp = TempDir::new().unwrap();
765 git_init(tmp.path());
766 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
767 std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
768
769 migrate(tmp.path()).unwrap();
771 }
772
773 #[test]
774 fn setup_docker_creates_dockerfile() {
775 let tmp = TempDir::new().unwrap();
776 git_init(tmp.path());
777 setup_docker(tmp.path()).unwrap();
778 let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
779 assert!(dockerfile.exists());
780 let contents = std::fs::read_to_string(&dockerfile).unwrap();
781 assert!(contents.contains("FROM rust:1.82-slim"));
782 assert!(contents.contains("claude"));
783 assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
784 }
785
786 #[test]
787 fn setup_docker_idempotent() {
788 let tmp = TempDir::new().unwrap();
789 git_init(tmp.path());
790 setup_docker(tmp.path()).unwrap();
791 let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
792 setup_docker(tmp.path()).unwrap();
794 let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
795 assert_eq!(before, after);
796 }
797
798 #[test]
799 fn default_config_escapes_special_chars() {
800 let name = r#"my\"project"#;
801 let description = r#"desc with "quotes" and \backslash"#;
802 let branch = "main";
803 let config = default_config(name, description, branch, &[]);
804 toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
805 }
806
807 #[test]
808 fn write_local_toml_creates_file() {
809 let tmp = TempDir::new().unwrap();
810 write_local_toml(tmp.path(), "alice").unwrap();
811 let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
812 assert!(contents.contains("username = \"alice\""));
813 }
814
815 #[test]
816 fn write_local_toml_idempotent() {
817 let tmp = TempDir::new().unwrap();
818 write_local_toml(tmp.path(), "alice").unwrap();
819 let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
820 write_local_toml(tmp.path(), "bob").unwrap();
821 let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
822 assert_eq!(first, second);
823 assert!(second.contains("alice"));
824 }
825
826 #[test]
827 fn setup_non_tty_no_local_toml() {
828 let tmp = TempDir::new().unwrap();
829 git_init(tmp.path());
830 setup(tmp.path(), None, None, None).unwrap();
831 assert!(!tmp.path().join(".apm/local.toml").exists());
832 }
833
834 #[test]
835 fn default_config_with_collaborators() {
836 let config = default_config("proj", "desc", "main", &["alice"]);
837 let parsed: toml::Value = toml::from_str(&config).unwrap();
838 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
839 assert_eq!(collaborators.len(), 1);
840 assert_eq!(collaborators[0].as_str().unwrap(), "alice");
841 }
842
843 #[test]
844 fn default_config_empty_collaborators() {
845 let config = default_config("proj", "desc", "main", &[]);
846 let parsed: toml::Value = toml::from_str(&config).unwrap();
847 let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
848 assert!(collaborators.is_empty());
849 }
850
851 #[test]
852 fn write_default_creates_new_file() {
853 let tmp = TempDir::new().unwrap();
854 let path = tmp.path().join("test.toml");
855 let mut msgs = Vec::new();
856 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
857 assert!(matches!(action, WriteAction::Created));
858 assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
859 }
860
861 #[test]
862 fn write_default_unchanged_when_identical() {
863 let tmp = TempDir::new().unwrap();
864 let path = tmp.path().join("test.toml");
865 std::fs::write(&path, "content").unwrap();
866 let mut msgs = Vec::new();
867 let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
868 assert!(matches!(action, WriteAction::Unchanged));
869 }
870
871 #[test]
872 fn write_default_non_tty_writes_init_when_differs() {
873 let tmp = TempDir::new().unwrap();
876 let path = tmp.path().join("test.toml");
877 std::fs::write(&path, "modified").unwrap();
878 let mut msgs = Vec::new();
879 let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
880 assert!(matches!(action, WriteAction::InitWritten));
881 assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
882 assert_eq!(
883 std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
884 "default"
885 );
886 }
887
888 #[test]
889 fn init_path_for_preserves_extension() {
890 let p = std::path::Path::new("/a/b/workflow.toml");
891 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
892
893 let p = std::path::Path::new("/a/b/agents.md");
894 assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
895 }
896
897 #[test]
898 fn setup_writes_init_files_when_content_differs() {
899 let tmp = TempDir::new().unwrap();
900 git_init(tmp.path());
901 setup(tmp.path(), None, None, None).unwrap();
903
904 let workflow = tmp.path().join(".apm/workflow.toml");
906 std::fs::write(&workflow, "# custom workflow\n").unwrap();
907
908 setup(tmp.path(), None, None, None).unwrap();
910 assert!(tmp.path().join(".apm/workflow.toml.init").exists());
911 assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
913 let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
915 assert_eq!(init_content, default_workflow_toml());
916 }
917
918 #[test]
919 fn setup_writes_config_init_when_modified() {
920 let tmp = TempDir::new().unwrap();
921 git_init(tmp.path());
922 setup(tmp.path(), None, None, None).unwrap();
923
924 let config_path = tmp.path().join(".apm/config.toml");
926 let mut content = std::fs::read_to_string(&config_path).unwrap();
927 content.push_str("\n[custom]\nfoo = \"bar\"\n");
928 std::fs::write(&config_path, &content).unwrap();
929
930 setup(tmp.path(), None, None, None).unwrap();
932 assert!(tmp.path().join(".apm/config.toml.init").exists());
933 assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
935 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
937 assert!(!init_content.contains("[custom]"));
938 assert!(init_content.contains("[project]"));
939 assert!(init_content.contains("[workers]"));
940 assert!(init_content.contains("collaborators = []"));
942 }
943
944 #[test]
945 fn setup_no_false_diff_when_collaborators_present() {
946 let tmp = TempDir::new().unwrap();
947 git_init(tmp.path());
948 setup(tmp.path(), None, None, Some("alice")).unwrap();
950
951 setup(tmp.path(), None, None, None).unwrap();
953
954 assert!(!tmp.path().join(".apm/config.toml.init").exists());
956 }
957
958 #[test]
959 fn setup_config_init_collaborators_match_live() {
960 let tmp = TempDir::new().unwrap();
961 git_init(tmp.path());
962 setup(tmp.path(), None, None, Some("alice")).unwrap();
963
964 let config_path = tmp.path().join(".apm/config.toml");
966 let mut content = std::fs::read_to_string(&config_path).unwrap();
967 content.push_str("\n[custom]\nfoo = \"bar\"\n");
968 std::fs::write(&config_path, &content).unwrap();
969
970 setup(tmp.path(), None, None, None).unwrap();
971
972 assert!(tmp.path().join(".apm/config.toml.init").exists());
974 let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
976 assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
977 }
978
979 #[test]
980 fn setup_migrates_flat_agent_files_to_agents_default() {
981 let tmp = TempDir::new().unwrap();
982 git_init(tmp.path());
983
984 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
986 std::fs::write(tmp.path().join(".apm/agents.md"), "# agents\n").unwrap();
987 std::fs::write(tmp.path().join(".apm/apm.spec-writer.md"), "# spec\n").unwrap();
988 std::fs::write(tmp.path().join(".apm/apm.worker.md"), "# worker\n").unwrap();
989 std::fs::write(tmp.path().join(".apm/style.md"), "# style\n").unwrap();
990 std::fs::write(
991 tmp.path().join("CLAUDE.md"),
992 "@.apm/agents.md\n@.apm/style.md\n",
993 )
994 .unwrap();
995
996 setup(tmp.path(), None, None, None).unwrap();
997
998 assert!(!tmp.path().join(".apm/agents.md").exists());
1000 assert!(!tmp.path().join(".apm/apm.spec-writer.md").exists());
1001 assert!(!tmp.path().join(".apm/apm.worker.md").exists());
1002 assert!(!tmp.path().join(".apm/style.md").exists());
1003
1004 assert!(tmp.path().join(".apm/agents/default/agents.md").exists());
1006 assert!(tmp.path().join(".apm/agents/default/apm.spec-writer.md").exists());
1007 assert!(tmp.path().join(".apm/agents/default/apm.worker.md").exists());
1008 assert!(tmp.path().join(".apm/agents/default/style.md").exists());
1009
1010 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1012 assert!(claude.contains("@.apm/project.md"));
1013 assert!(claude.contains("@.apm/agents/default/apm.main-agent.md"));
1014 assert!(!claude.contains("@.apm/agents/default/agents.md"));
1015 assert!(claude.contains("@.apm/agents/default/style.md"));
1016 assert!(!claude.contains("@.apm/agents.md"));
1017 assert!(!claude.contains("@.apm/style.md"));
1018 }
1019
1020 #[test]
1021 fn setup_no_false_diff_empty_collaborators() {
1022 let tmp = TempDir::new().unwrap();
1023 git_init(tmp.path());
1024 setup(tmp.path(), None, None, None).unwrap();
1026 setup(tmp.path(), None, None, None).unwrap();
1028 assert!(!tmp.path().join(".apm/config.toml.init").exists());
1029 }
1030
1031 #[test]
1032 fn default_workflow_toml_is_valid() {
1033 use crate::config::{SatisfiesDeps, WorkflowFile};
1034
1035 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1036 let states = &parsed.workflow.states;
1037
1038 let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
1039 assert_eq!(
1040 ids,
1041 ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
1042 );
1043
1044 for id in ["groomed", "ammend"] {
1045 let s = states.iter().find(|s| s.id == id).unwrap();
1046 assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
1047 }
1048
1049 for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
1050 let s = states.iter().find(|s| s.id == id).unwrap();
1051 assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
1052 }
1053 }
1054
1055 #[test]
1056 fn default_workflow_all_transitions_have_valid_outcomes() {
1057 use crate::config::{resolve_outcome, WorkflowFile};
1058
1059 let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
1060 let states = &parsed.workflow.states;
1061 let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
1062 states.iter().map(|s| (s.id.as_str(), s)).collect();
1063
1064 let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"];
1065
1066 for state in states {
1067 for t in &state.transitions {
1068 let target = state_map
1069 .get(t.to.as_str())
1070 .unwrap_or_else(|| panic!("target state '{}' not found in map", t.to));
1071 let outcome = resolve_outcome(t, target);
1072 assert!(
1073 !outcome.is_empty(),
1074 "transition {} → {} has empty outcome",
1075 state.id, t.to
1076 );
1077 assert!(
1078 valid_outcomes.contains(&outcome),
1079 "transition {} → {} has unexpected outcome '{outcome}'",
1080 state.id, t.to
1081 );
1082 }
1083 }
1084 }
1085
1086 #[test]
1087 fn default_ticket_toml_is_valid() {
1088 use crate::config::TicketFile;
1089
1090 let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
1091 let sections = &parsed.ticket.sections;
1092
1093 for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
1094 let s = sections.iter().find(|s| s.name == name).unwrap();
1095 assert!(s.required, "section '{name}' should be required");
1096 }
1097 }
1098
1099 #[test]
1100 fn default_config_has_in_repo_worktrees_dir() {
1101 let config = default_config("myproj", "desc", "main", &[]);
1102 assert!(
1103 config.contains("dir = \".apm--worktrees\""),
1104 "default config should use .apm--worktrees dir: {config}"
1105 );
1106 }
1107
1108 #[test]
1109 fn setup_gitignore_includes_worktrees_pattern() {
1110 let tmp = TempDir::new().unwrap();
1111 git_init(tmp.path());
1112 setup(tmp.path(), None, None, None).unwrap();
1113 let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
1114 assert!(contents.contains("/.apm--worktrees/"), ".gitignore must contain /.apm--worktrees/");
1115 assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
1116 }
1117
1118 #[test]
1119 fn ensure_gitignore_worktrees_idempotent() {
1120 let tmp = TempDir::new().unwrap();
1121 let path = tmp.path().join(".gitignore");
1122 let mut msgs = Vec::new();
1123 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1124 let before = std::fs::read_to_string(&path).unwrap();
1125 ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
1126 let after = std::fs::read_to_string(&path).unwrap();
1127 assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
1128 let count = before.matches("/worktrees/").count();
1129 assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
1130 }
1131
1132 #[test]
1133 fn setup_creates_worktrees_dir_inside_repo() {
1134 let tmp = TempDir::new().unwrap();
1135 git_init(tmp.path());
1136 setup(tmp.path(), None, None, None).unwrap();
1137 assert!(
1138 tmp.path().join(".apm--worktrees").exists(),
1139 "worktrees dir should be created inside the repo"
1140 );
1141 }
1142
1143 #[test]
1144 fn worktree_gitignore_pattern_simple() {
1145 assert_eq!(
1146 worktree_gitignore_pattern(std::path::Path::new("worktrees")),
1147 Some("/worktrees/".to_string())
1148 );
1149 }
1150
1151 #[test]
1152 fn worktree_gitignore_pattern_hidden_dir() {
1153 assert_eq!(
1154 worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
1155 Some("/.apm--worktrees/".to_string())
1156 );
1157 }
1158
1159 #[test]
1160 fn worktree_gitignore_pattern_nested() {
1161 assert_eq!(
1162 worktree_gitignore_pattern(std::path::Path::new("build/wt")),
1163 Some("/build/wt/".to_string())
1164 );
1165 }
1166
1167 #[test]
1168 fn worktree_gitignore_pattern_absolute_is_none() {
1169 assert_eq!(
1170 worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
1171 None
1172 );
1173 }
1174
1175 #[test]
1176 fn worktree_gitignore_pattern_parent_relative_is_none() {
1177 assert_eq!(
1178 worktree_gitignore_pattern(std::path::Path::new("../external")),
1179 None
1180 );
1181 }
1182
1183 #[test]
1184 fn default_config_has_project_key() {
1185 let config = default_config("proj", "desc", "main", &[]);
1186 assert!(config.contains("project = \".apm/project.md\""));
1187 assert!(!config.contains("instructions = "));
1188 }
1189
1190 #[test]
1191 fn setup_claude_md_contains_new_imports() {
1192 let tmp = TempDir::new().unwrap();
1193 git_init(tmp.path());
1194 setup(tmp.path(), None, None, None).unwrap();
1195
1196 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1197 assert!(claude.contains("@.apm/project.md"));
1198 assert!(claude.contains("@.apm/agents/default/apm.main-agent.md"));
1199 assert!(!claude.contains("@.apm/agents/default/agents.md"));
1200 }
1201
1202 #[test]
1203 fn migrate_agents_md_config_instructions_to_project_key() {
1204 let tmp = TempDir::new().unwrap();
1205 git_init(tmp.path());
1206
1207 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1209 std::fs::write(
1210 tmp.path().join(".apm/config.toml"),
1211 "[agents]\ninstructions = \".apm/agents/default/agents.md\"\n",
1212 ).unwrap();
1213 std::fs::write(
1214 tmp.path().join("CLAUDE.md"),
1215 "@.apm/agents/default/agents.md\n",
1216 ).unwrap();
1217
1218 setup(tmp.path(), None, None, None).unwrap();
1219
1220 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1221 assert!(config.contains("project = \".apm/project.md\""));
1222 assert!(!config.contains("instructions = \".apm/agents/default/agents.md\""));
1223
1224 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1225 assert!(claude.contains("@.apm/project.md"));
1226 assert!(claude.contains("@.apm/agents/default/apm.main-agent.md"));
1227 assert!(!claude.contains("@.apm/agents/default/agents.md"));
1228 }
1229
1230 #[test]
1231 fn migrate_project_md_from_agents_default() {
1232 let tmp = TempDir::new().unwrap();
1233 git_init(tmp.path());
1234 std::fs::create_dir_all(tmp.path().join(".apm/agents/default")).unwrap();
1235 std::fs::write(tmp.path().join(".apm/agents/default/apm.project.md"), "old location").unwrap();
1236 std::fs::write(tmp.path().join("CLAUDE.md"), "@.apm/agents/default/apm.project.md\n").unwrap();
1237 std::fs::write(
1238 tmp.path().join(".apm/config.toml"),
1239 "[project]\nname = \"test\"\ndefault_branch = \"main\"\n\n[agents]\nproject = \".apm/agents/default/apm.project.md\"\n",
1240 ).unwrap();
1241
1242 setup(tmp.path(), None, None, None).unwrap();
1243
1244 assert!(tmp.path().join(".apm/project.md").exists());
1245 assert!(!tmp.path().join(".apm/agents/default/apm.project.md").exists());
1246 let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
1247 assert!(claude.contains("@.apm/project.md"));
1248 assert!(!claude.contains("@.apm/agents/default/apm.project.md"));
1249 let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
1250 assert!(config.contains("project = \".apm/project.md\""));
1251 assert!(!config.contains("project = \".apm/agents/default/apm.project.md\""));
1252 }
1253}