Skip to main content

apm_core/
init.rs

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