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