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]
309agent = "claude"
310
311[workers.options]
312model = "sonnet"
313
314[worker_profiles.spec_agent]
315instructions = ".apm/apm.spec-writer.md"
316role = "spec-writer"
317role_prefix = "You are a Spec-Writer agent assigned to ticket #<id>."
318
319[worker_profiles.impl_agent]
320instructions = ".apm/apm.worker.md"
321role_prefix = "You are a Worker agent assigned to ticket #<id>."
322
323[logging]
324enabled = false
325file = "{log_file}"
326"##
327    )
328}
329
330fn write_local_toml(apm_dir: &Path, username: &str) -> Result<()> {
331    let path = apm_dir.join("local.toml");
332    if !path.exists() {
333        let username_escaped = toml_escape(username);
334        std::fs::write(&path, format!("username = \"{username_escaped}\"\n"))?;
335    }
336    Ok(())
337}
338
339pub fn default_workflow_toml() -> &'static str {
340    include_str!("default/workflow.toml")
341}
342
343/// Returns a map from transition `to` value → `on_failure` state name for every
344/// `Merge` or `PrOrEpicMerge` transition declared in the default workflow template.
345pub fn default_on_failure_map() -> std::collections::HashMap<String, String> {
346    #[derive(serde::Deserialize)]
347    struct Wrapper {
348        workflow: crate::config::WorkflowConfig,
349    }
350    let w: Wrapper = toml::from_str(include_str!("default/workflow.toml"))
351        .expect("default workflow.toml is valid TOML");
352    let mut map = std::collections::HashMap::new();
353    for state in &w.workflow.states {
354        for tr in &state.transitions {
355            if matches!(
356                tr.completion,
357                crate::config::CompletionStrategy::Merge
358                    | crate::config::CompletionStrategy::PrOrEpicMerge
359            ) {
360                if let Some(ref of) = tr.on_failure {
361                    map.insert(tr.to.clone(), of.clone());
362                }
363            }
364        }
365    }
366    map
367}
368
369fn default_ticket_toml() -> &'static str {
370    include_str!("default/ticket.toml")
371}
372
373fn maybe_initial_commit(root: &Path, messages: &mut Vec<String>) -> Result<()> {
374    if crate::git_util::has_commits(root) {
375        return Ok(());
376    }
377
378    crate::git_util::stage_files(root, &[
379        ".apm/config.toml", ".apm/workflow.toml", ".apm/ticket.toml", ".gitignore",
380    ])?;
381
382    if crate::git_util::commit(root, "apm: initialize project").is_ok() {
383        messages.push("Created initial commit.".to_string());
384    }
385    Ok(())
386}
387
388fn ensure_worktrees_dir(root: &Path, messages: &mut Vec<String>) -> Result<()> {
389    if let Ok(config) = crate::config::Config::load(root) {
390        let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
391        let wt_dir = main_root.join(&config.worktrees.dir);
392        if !wt_dir.exists() {
393            std::fs::create_dir_all(&wt_dir)?;
394            messages.push(format!("Created worktrees dir: {}", wt_dir.display()));
395        }
396    }
397    Ok(())
398}
399
400pub fn setup_docker(root: &Path) -> Result<SetupDockerOutput> {
401    let mut messages: Vec<String> = Vec::new();
402    let apm_dir = root.join(".apm");
403    std::fs::create_dir_all(&apm_dir)?;
404    let dockerfile_path = apm_dir.join("Dockerfile.apm-worker");
405    if dockerfile_path.exists() {
406        messages.push(".apm/Dockerfile.apm-worker already exists — not overwriting.".to_string());
407        return Ok(SetupDockerOutput { messages });
408    }
409    std::fs::write(&dockerfile_path, DOCKERFILE_TEMPLATE)?;
410    messages.push("Created .apm/Dockerfile.apm-worker".to_string());
411    messages.push(String::new());
412    messages.push("Next steps:".to_string());
413    messages.push("  1. Review .apm/Dockerfile.apm-worker and add project-specific dependencies.".to_string());
414    messages.push("  2. Build the image:".to_string());
415    messages.push("       docker build -f .apm/Dockerfile.apm-worker -t apm-worker .".to_string());
416    messages.push("  3. Add to .apm/config.toml:".to_string());
417    messages.push("       [workers]".to_string());
418    messages.push("       container = \"apm-worker\"".to_string());
419    messages.push("  4. Configure credential lookup (optional, macOS only):".to_string());
420    messages.push("       [workers.keychain]".to_string());
421    messages.push("       ANTHROPIC_API_KEY = \"anthropic-api-key\"".to_string());
422    Ok(SetupDockerOutput { messages })
423}
424
425const DOCKERFILE_TEMPLATE: &str = r#"FROM rust:1.82-slim
426
427# System tools
428RUN apt-get update && apt-get install -y \
429    curl git unzip ca-certificates && \
430    rm -rf /var/lib/apt/lists/*
431
432# Claude CLI
433RUN curl -fsSL https://storage.googleapis.com/anthropic-claude-cli/install.sh | sh
434
435# apm binary (replace with your version or a downloaded release)
436COPY target/release/apm /usr/local/bin/apm
437
438# Add project-specific dependencies here:
439# RUN apt-get install -y nodejs npm   # for Node projects
440# RUN pip install -r requirements.txt # for Python projects
441
442# gh CLI is NOT needed — the worker only runs local git commits;
443# push and PR creation happen on the host via apm state <id> implemented.
444
445WORKDIR /workspace
446"#;
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::process::Command;
452    use tempfile::TempDir;
453
454    fn git_init(dir: &Path) {
455        Command::new("git")
456            .args(["init", "-b", "main"])
457            .current_dir(dir)
458            .output()
459            .unwrap();
460        Command::new("git")
461            .args(["config", "user.email", "test@test.com"])
462            .current_dir(dir)
463            .output()
464            .unwrap();
465        Command::new("git")
466            .args(["config", "user.name", "Test"])
467            .current_dir(dir)
468            .output()
469            .unwrap();
470    }
471
472    #[test]
473    fn detect_default_branch_fresh_repo() {
474        let tmp = TempDir::new().unwrap();
475        git_init(tmp.path());
476        let branch = detect_default_branch(tmp.path());
477        assert_eq!(branch, "main");
478    }
479
480    #[test]
481    fn detect_default_branch_non_git() {
482        let tmp = TempDir::new().unwrap();
483        let branch = detect_default_branch(tmp.path());
484        assert_eq!(branch, "main");
485    }
486
487    #[test]
488    fn ensure_gitignore_creates_file() {
489        let tmp = TempDir::new().unwrap();
490        let path = tmp.path().join(".gitignore");
491        let mut msgs = Vec::new();
492        ensure_gitignore(&path, None, &mut msgs).unwrap();
493        let contents = std::fs::read_to_string(&path).unwrap();
494        assert!(contents.contains(".apm/local.toml"));
495        assert!(contents.contains(".apm/*.init"));
496        assert!(contents.contains(".apm/sessions.json"));
497        assert!(contents.contains(".apm/credentials.json"));
498    }
499
500    #[test]
501    fn ensure_gitignore_appends_missing_entry() {
502        let tmp = TempDir::new().unwrap();
503        let path = tmp.path().join(".gitignore");
504        std::fs::write(&path, "node_modules\n").unwrap();
505        let mut msgs = Vec::new();
506        ensure_gitignore(&path, None, &mut msgs).unwrap();
507        let contents = std::fs::read_to_string(&path).unwrap();
508        assert!(contents.contains("node_modules"));
509        assert!(contents.contains(".apm/local.toml"));
510    }
511
512    #[test]
513    fn ensure_gitignore_idempotent() {
514        let tmp = TempDir::new().unwrap();
515        let path = tmp.path().join(".gitignore");
516        let mut msgs = Vec::new();
517        ensure_gitignore(&path, None, &mut msgs).unwrap();
518        let before = std::fs::read_to_string(&path).unwrap();
519        ensure_gitignore(&path, None, &mut msgs).unwrap();
520        let after = std::fs::read_to_string(&path).unwrap();
521        assert_eq!(before, after);
522    }
523
524    #[test]
525    fn setup_creates_expected_files() {
526        let tmp = TempDir::new().unwrap();
527        git_init(tmp.path());
528        setup(tmp.path(), None, None, None).unwrap();
529
530        assert!(tmp.path().join("tickets").exists());
531        assert!(tmp.path().join(".apm/config.toml").exists());
532        assert!(tmp.path().join(".apm/workflow.toml").exists());
533        assert!(tmp.path().join(".apm/ticket.toml").exists());
534        assert!(tmp.path().join(".apm/agents.md").exists());
535        assert!(tmp.path().join(".apm/apm.spec-writer.md").exists());
536        assert!(tmp.path().join(".apm/apm.worker.md").exists());
537        assert!(tmp.path().join(".gitignore").exists());
538        assert!(tmp.path().join("CLAUDE.md").exists());
539    }
540
541    #[test]
542    fn setup_non_tty_uses_dir_name_and_empty_description() {
543        let tmp = TempDir::new().unwrap();
544        git_init(tmp.path());
545        setup(tmp.path(), None, None, None).unwrap();
546
547        let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
548        let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
549        assert!(config.contains(&format!("name = \"{dir_name}\"")));
550        assert!(config.contains("description = \"\""));
551    }
552
553    #[test]
554    fn setup_is_idempotent() {
555        let tmp = TempDir::new().unwrap();
556        git_init(tmp.path());
557        setup(tmp.path(), None, None, None).unwrap();
558
559        // Write sentinel content to config
560        let config_path = tmp.path().join(".apm/config.toml");
561        let original = std::fs::read_to_string(&config_path).unwrap();
562
563        setup(tmp.path(), None, None, None).unwrap();
564        let after = std::fs::read_to_string(&config_path).unwrap();
565        assert_eq!(original, after);
566    }
567
568    #[test]
569    fn migrate_moves_files_and_updates_claude_md() {
570        let tmp = TempDir::new().unwrap();
571        git_init(tmp.path());
572
573        std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
574        std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
575        std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
576
577        migrate(tmp.path()).unwrap();
578
579        assert!(tmp.path().join(".apm/config.toml").exists());
580        assert!(tmp.path().join(".apm/agents.md").exists());
581        assert!(!tmp.path().join("apm.toml").exists());
582        assert!(!tmp.path().join("apm.agents.md").exists());
583
584        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
585        assert!(claude.contains("@.apm/agents.md"));
586        assert!(!claude.contains("@apm.agents.md"));
587    }
588
589    #[test]
590    fn migrate_already_migrated() {
591        let tmp = TempDir::new().unwrap();
592        git_init(tmp.path());
593        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
594        std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
595
596        // Should not panic or error
597        migrate(tmp.path()).unwrap();
598    }
599
600    #[test]
601    fn setup_docker_creates_dockerfile() {
602        let tmp = TempDir::new().unwrap();
603        git_init(tmp.path());
604        setup_docker(tmp.path()).unwrap();
605        let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
606        assert!(dockerfile.exists());
607        let contents = std::fs::read_to_string(&dockerfile).unwrap();
608        assert!(contents.contains("FROM rust:1.82-slim"));
609        assert!(contents.contains("claude"));
610        assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
611    }
612
613    #[test]
614    fn setup_docker_idempotent() {
615        let tmp = TempDir::new().unwrap();
616        git_init(tmp.path());
617        setup_docker(tmp.path()).unwrap();
618        let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
619        // Second call should not overwrite
620        setup_docker(tmp.path()).unwrap();
621        let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
622        assert_eq!(before, after);
623    }
624
625    #[test]
626    fn default_config_escapes_special_chars() {
627        let name = r#"my\"project"#;
628        let description = r#"desc with "quotes" and \backslash"#;
629        let branch = "main";
630        let config = default_config(name, description, branch, &[]);
631        toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
632    }
633
634    #[test]
635    fn write_local_toml_creates_file() {
636        let tmp = TempDir::new().unwrap();
637        write_local_toml(tmp.path(), "alice").unwrap();
638        let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
639        assert!(contents.contains("username = \"alice\""));
640    }
641
642    #[test]
643    fn write_local_toml_idempotent() {
644        let tmp = TempDir::new().unwrap();
645        write_local_toml(tmp.path(), "alice").unwrap();
646        let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
647        write_local_toml(tmp.path(), "bob").unwrap();
648        let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
649        assert_eq!(first, second);
650        assert!(second.contains("alice"));
651    }
652
653    #[test]
654    fn setup_non_tty_no_local_toml() {
655        let tmp = TempDir::new().unwrap();
656        git_init(tmp.path());
657        setup(tmp.path(), None, None, None).unwrap();
658        assert!(!tmp.path().join(".apm/local.toml").exists());
659    }
660
661    #[test]
662    fn default_config_with_collaborators() {
663        let config = default_config("proj", "desc", "main", &["alice"]);
664        let parsed: toml::Value = toml::from_str(&config).unwrap();
665        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
666        assert_eq!(collaborators.len(), 1);
667        assert_eq!(collaborators[0].as_str().unwrap(), "alice");
668    }
669
670    #[test]
671    fn default_config_empty_collaborators() {
672        let config = default_config("proj", "desc", "main", &[]);
673        let parsed: toml::Value = toml::from_str(&config).unwrap();
674        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
675        assert!(collaborators.is_empty());
676    }
677
678    #[test]
679    fn write_default_creates_new_file() {
680        let tmp = TempDir::new().unwrap();
681        let path = tmp.path().join("test.toml");
682        let mut msgs = Vec::new();
683        let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
684        assert!(matches!(action, WriteAction::Created));
685        assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
686    }
687
688    #[test]
689    fn write_default_unchanged_when_identical() {
690        let tmp = TempDir::new().unwrap();
691        let path = tmp.path().join("test.toml");
692        std::fs::write(&path, "content").unwrap();
693        let mut msgs = Vec::new();
694        let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
695        assert!(matches!(action, WriteAction::Unchanged));
696    }
697
698    #[test]
699    fn write_default_non_tty_writes_init_when_differs() {
700        // In test context stdin is not a terminal, so this exercises
701        // the non-interactive path: write .init copy.
702        let tmp = TempDir::new().unwrap();
703        let path = tmp.path().join("test.toml");
704        std::fs::write(&path, "modified").unwrap();
705        let mut msgs = Vec::new();
706        let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
707        assert!(matches!(action, WriteAction::InitWritten));
708        assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
709        assert_eq!(
710            std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
711            "default"
712        );
713    }
714
715    #[test]
716    fn init_path_for_preserves_extension() {
717        let p = std::path::Path::new("/a/b/workflow.toml");
718        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
719
720        let p = std::path::Path::new("/a/b/agents.md");
721        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
722    }
723
724    #[test]
725    fn setup_writes_init_files_when_content_differs() {
726        let tmp = TempDir::new().unwrap();
727        git_init(tmp.path());
728        // First setup: creates all files
729        setup(tmp.path(), None, None, None).unwrap();
730
731        // Modify a file
732        let workflow = tmp.path().join(".apm/workflow.toml");
733        std::fs::write(&workflow, "# custom workflow\n").unwrap();
734
735        // Second setup (non-tty): should write .init copy
736        setup(tmp.path(), None, None, None).unwrap();
737        assert!(tmp.path().join(".apm/workflow.toml.init").exists());
738        // Original should be untouched
739        assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
740        // .init should have the default content
741        let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
742        assert_eq!(init_content, default_workflow_toml());
743    }
744
745    #[test]
746    fn setup_writes_config_init_when_modified() {
747        let tmp = TempDir::new().unwrap();
748        git_init(tmp.path());
749        setup(tmp.path(), None, None, None).unwrap();
750
751        // Modify config.toml (add a custom section)
752        let config_path = tmp.path().join(".apm/config.toml");
753        let mut content = std::fs::read_to_string(&config_path).unwrap();
754        content.push_str("\n[custom]\nfoo = \"bar\"\n");
755        std::fs::write(&config_path, &content).unwrap();
756
757        // Second setup (non-tty): should write config.toml.init
758        setup(tmp.path(), None, None, None).unwrap();
759        assert!(tmp.path().join(".apm/config.toml.init").exists());
760        // Original should be untouched
761        assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
762        // .init should be the default for this project's name/branch
763        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
764        assert!(!init_content.contains("[custom]"));
765        assert!(init_content.contains("[project]"));
766        assert!(init_content.contains("[workers]"));
767        // .init should carry the same collaborators as the live config
768        assert!(init_content.contains("collaborators = []"));
769    }
770
771    #[test]
772    fn setup_no_false_diff_when_collaborators_present() {
773        let tmp = TempDir::new().unwrap();
774        git_init(tmp.path());
775        // First run: create config with collaborators = ["alice"]
776        setup(tmp.path(), None, None, Some("alice")).unwrap();
777
778        // Re-run without username (simulates non-TTY subsequent call)
779        setup(tmp.path(), None, None, None).unwrap();
780
781        // Should NOT produce a false diff
782        assert!(!tmp.path().join(".apm/config.toml.init").exists());
783    }
784
785    #[test]
786    fn setup_config_init_collaborators_match_live() {
787        let tmp = TempDir::new().unwrap();
788        git_init(tmp.path());
789        setup(tmp.path(), None, None, Some("alice")).unwrap();
790
791        // Manually edit config to add a custom section (to trigger a real diff)
792        let config_path = tmp.path().join(".apm/config.toml");
793        let mut content = std::fs::read_to_string(&config_path).unwrap();
794        content.push_str("\n[custom]\nfoo = \"bar\"\n");
795        std::fs::write(&config_path, &content).unwrap();
796
797        setup(tmp.path(), None, None, None).unwrap();
798
799        // .init must exist (real diff)
800        assert!(tmp.path().join(".apm/config.toml.init").exists());
801        // .init should contain alice's collaborators, not an empty array
802        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
803        assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
804    }
805
806    #[test]
807    fn setup_no_false_diff_empty_collaborators() {
808        let tmp = TempDir::new().unwrap();
809        git_init(tmp.path());
810        // First run: no username → collaborators = []
811        setup(tmp.path(), None, None, None).unwrap();
812        // Re-run: should still be idempotent
813        setup(tmp.path(), None, None, None).unwrap();
814        assert!(!tmp.path().join(".apm/config.toml.init").exists());
815    }
816
817    #[test]
818    fn default_workflow_toml_is_valid() {
819        use crate::config::{SatisfiesDeps, WorkflowFile};
820
821        let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
822        let states = &parsed.workflow.states;
823
824        let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
825        assert_eq!(
826            ids,
827            ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
828        );
829
830        for id in ["groomed", "ammend"] {
831            let s = states.iter().find(|s| s.id == id).unwrap();
832            assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
833        }
834
835        for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
836            let s = states.iter().find(|s| s.id == id).unwrap();
837            assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
838        }
839    }
840
841    #[test]
842    fn default_workflow_all_transitions_have_valid_outcomes() {
843        use crate::config::{resolve_outcome, WorkflowFile};
844
845        let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
846        let states = &parsed.workflow.states;
847        let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
848            states.iter().map(|s| (s.id.as_str(), s)).collect();
849
850        let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"];
851
852        for state in states {
853            for t in &state.transitions {
854                let target = state_map
855                    .get(t.to.as_str())
856                    .unwrap_or_else(|| panic!("target state '{}' not found in map", t.to));
857                let outcome = resolve_outcome(t, target);
858                assert!(
859                    !outcome.is_empty(),
860                    "transition {} → {} has empty outcome",
861                    state.id, t.to
862                );
863                assert!(
864                    valid_outcomes.contains(&outcome),
865                    "transition {} → {} has unexpected outcome '{outcome}'",
866                    state.id, t.to
867                );
868            }
869        }
870    }
871
872    #[test]
873    fn default_ticket_toml_is_valid() {
874        use crate::config::TicketFile;
875
876        let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
877        let sections = &parsed.ticket.sections;
878
879        for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
880            let s = sections.iter().find(|s| s.name == name).unwrap();
881            assert!(s.required, "section '{name}' should be required");
882        }
883    }
884
885    #[test]
886    fn default_config_has_in_repo_worktrees_dir() {
887        let config = default_config("myproj", "desc", "main", &[]);
888        assert!(
889            config.contains("dir = \"worktrees\""),
890            "default config should use in-repo worktrees dir: {config}"
891        );
892        assert!(
893            !config.contains("--worktrees"),
894            "default config must not reference the old external layout: {config}"
895        );
896    }
897
898    #[test]
899    fn setup_gitignore_includes_worktrees_pattern() {
900        let tmp = TempDir::new().unwrap();
901        git_init(tmp.path());
902        setup(tmp.path(), None, None, None).unwrap();
903        let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
904        assert!(contents.contains("/worktrees/"), ".gitignore must contain /worktrees/");
905        assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
906    }
907
908    #[test]
909    fn ensure_gitignore_worktrees_idempotent() {
910        let tmp = TempDir::new().unwrap();
911        let path = tmp.path().join(".gitignore");
912        let mut msgs = Vec::new();
913        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
914        let before = std::fs::read_to_string(&path).unwrap();
915        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
916        let after = std::fs::read_to_string(&path).unwrap();
917        assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
918        let count = before.matches("/worktrees/").count();
919        assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
920    }
921
922    #[test]
923    fn setup_creates_worktrees_dir_inside_repo() {
924        let tmp = TempDir::new().unwrap();
925        git_init(tmp.path());
926        setup(tmp.path(), None, None, None).unwrap();
927        assert!(
928            tmp.path().join("worktrees").exists(),
929            "worktrees dir should be created inside the repo"
930        );
931    }
932
933    #[test]
934    fn worktree_gitignore_pattern_simple() {
935        assert_eq!(
936            worktree_gitignore_pattern(std::path::Path::new("worktrees")),
937            Some("/worktrees/".to_string())
938        );
939    }
940
941    #[test]
942    fn worktree_gitignore_pattern_hidden_dir() {
943        assert_eq!(
944            worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
945            Some("/.apm--worktrees/".to_string())
946        );
947    }
948
949    #[test]
950    fn worktree_gitignore_pattern_nested() {
951        assert_eq!(
952            worktree_gitignore_pattern(std::path::Path::new("build/wt")),
953            Some("/build/wt/".to_string())
954        );
955    }
956
957    #[test]
958    fn worktree_gitignore_pattern_absolute_is_none() {
959        assert_eq!(
960            worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
961            None
962        );
963    }
964
965    #[test]
966    fn worktree_gitignore_pattern_parent_relative_is_none() {
967        assert_eq!(
968            worktree_gitignore_pattern(std::path::Path::new("../external")),
969            None
970        );
971    }
972}