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 = ["tickets/NEXT_ID", ".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("tickets/NEXT_ID"));
496        assert!(contents.contains(".apm/local.toml"));
497        assert!(contents.contains(".apm/*.init"));
498        assert!(contents.contains(".apm/sessions.json"));
499        assert!(contents.contains(".apm/credentials.json"));
500    }
501
502    #[test]
503    fn ensure_gitignore_appends_missing_entry() {
504        let tmp = TempDir::new().unwrap();
505        let path = tmp.path().join(".gitignore");
506        std::fs::write(&path, "node_modules\n").unwrap();
507        let mut msgs = Vec::new();
508        ensure_gitignore(&path, None, &mut msgs).unwrap();
509        let contents = std::fs::read_to_string(&path).unwrap();
510        assert!(contents.contains("node_modules"));
511        assert!(contents.contains("tickets/NEXT_ID"));
512    }
513
514    #[test]
515    fn ensure_gitignore_idempotent() {
516        let tmp = TempDir::new().unwrap();
517        let path = tmp.path().join(".gitignore");
518        let mut msgs = Vec::new();
519        ensure_gitignore(&path, None, &mut msgs).unwrap();
520        let before = std::fs::read_to_string(&path).unwrap();
521        ensure_gitignore(&path, None, &mut msgs).unwrap();
522        let after = std::fs::read_to_string(&path).unwrap();
523        assert_eq!(before, after);
524    }
525
526    #[test]
527    fn setup_creates_expected_files() {
528        let tmp = TempDir::new().unwrap();
529        git_init(tmp.path());
530        setup(tmp.path(), None, None, None).unwrap();
531
532        assert!(tmp.path().join("tickets").exists());
533        assert!(tmp.path().join(".apm/config.toml").exists());
534        assert!(tmp.path().join(".apm/workflow.toml").exists());
535        assert!(tmp.path().join(".apm/ticket.toml").exists());
536        assert!(tmp.path().join(".apm/agents.md").exists());
537        assert!(tmp.path().join(".apm/apm.spec-writer.md").exists());
538        assert!(tmp.path().join(".apm/apm.worker.md").exists());
539        assert!(tmp.path().join(".gitignore").exists());
540        assert!(tmp.path().join("CLAUDE.md").exists());
541    }
542
543    #[test]
544    fn setup_non_tty_uses_dir_name_and_empty_description() {
545        let tmp = TempDir::new().unwrap();
546        git_init(tmp.path());
547        setup(tmp.path(), None, None, None).unwrap();
548
549        let config = std::fs::read_to_string(tmp.path().join(".apm/config.toml")).unwrap();
550        let dir_name = tmp.path().file_name().unwrap().to_str().unwrap();
551        assert!(config.contains(&format!("name = \"{dir_name}\"")));
552        assert!(config.contains("description = \"\""));
553    }
554
555    #[test]
556    fn setup_is_idempotent() {
557        let tmp = TempDir::new().unwrap();
558        git_init(tmp.path());
559        setup(tmp.path(), None, None, None).unwrap();
560
561        // Write sentinel content to config
562        let config_path = tmp.path().join(".apm/config.toml");
563        let original = std::fs::read_to_string(&config_path).unwrap();
564
565        setup(tmp.path(), None, None, None).unwrap();
566        let after = std::fs::read_to_string(&config_path).unwrap();
567        assert_eq!(original, after);
568    }
569
570    #[test]
571    fn migrate_moves_files_and_updates_claude_md() {
572        let tmp = TempDir::new().unwrap();
573        git_init(tmp.path());
574
575        std::fs::write(tmp.path().join("apm.toml"), "[project]\nname = \"x\"\n").unwrap();
576        std::fs::write(tmp.path().join("apm.agents.md"), "# agents\n").unwrap();
577        std::fs::write(tmp.path().join("CLAUDE.md"), "@apm.agents.md\n\nContent\n").unwrap();
578
579        migrate(tmp.path()).unwrap();
580
581        assert!(tmp.path().join(".apm/config.toml").exists());
582        assert!(tmp.path().join(".apm/agents.md").exists());
583        assert!(!tmp.path().join("apm.toml").exists());
584        assert!(!tmp.path().join("apm.agents.md").exists());
585
586        let claude = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
587        assert!(claude.contains("@.apm/agents.md"));
588        assert!(!claude.contains("@apm.agents.md"));
589    }
590
591    #[test]
592    fn migrate_already_migrated() {
593        let tmp = TempDir::new().unwrap();
594        git_init(tmp.path());
595        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
596        std::fs::write(tmp.path().join(".apm/config.toml"), "").unwrap();
597
598        // Should not panic or error
599        migrate(tmp.path()).unwrap();
600    }
601
602    #[test]
603    fn setup_docker_creates_dockerfile() {
604        let tmp = TempDir::new().unwrap();
605        git_init(tmp.path());
606        setup_docker(tmp.path()).unwrap();
607        let dockerfile = tmp.path().join(".apm/Dockerfile.apm-worker");
608        assert!(dockerfile.exists());
609        let contents = std::fs::read_to_string(&dockerfile).unwrap();
610        assert!(contents.contains("FROM rust:1.82-slim"));
611        assert!(contents.contains("claude"));
612        assert!(!contents.contains("gh CLI") || contents.contains("NOT needed"));
613    }
614
615    #[test]
616    fn setup_docker_idempotent() {
617        let tmp = TempDir::new().unwrap();
618        git_init(tmp.path());
619        setup_docker(tmp.path()).unwrap();
620        let before = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
621        // Second call should not overwrite
622        setup_docker(tmp.path()).unwrap();
623        let after = std::fs::read_to_string(tmp.path().join(".apm/Dockerfile.apm-worker")).unwrap();
624        assert_eq!(before, after);
625    }
626
627    #[test]
628    fn default_config_escapes_special_chars() {
629        let name = r#"my\"project"#;
630        let description = r#"desc with "quotes" and \backslash"#;
631        let branch = "main";
632        let config = default_config(name, description, branch, &[]);
633        toml::from_str::<toml::Value>(&config).expect("default_config output must be valid TOML");
634    }
635
636    #[test]
637    fn write_local_toml_creates_file() {
638        let tmp = TempDir::new().unwrap();
639        write_local_toml(tmp.path(), "alice").unwrap();
640        let contents = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
641        assert!(contents.contains("username = \"alice\""));
642    }
643
644    #[test]
645    fn write_local_toml_idempotent() {
646        let tmp = TempDir::new().unwrap();
647        write_local_toml(tmp.path(), "alice").unwrap();
648        let first = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
649        write_local_toml(tmp.path(), "bob").unwrap();
650        let second = std::fs::read_to_string(tmp.path().join("local.toml")).unwrap();
651        assert_eq!(first, second);
652        assert!(second.contains("alice"));
653    }
654
655    #[test]
656    fn setup_non_tty_no_local_toml() {
657        let tmp = TempDir::new().unwrap();
658        git_init(tmp.path());
659        setup(tmp.path(), None, None, None).unwrap();
660        assert!(!tmp.path().join(".apm/local.toml").exists());
661    }
662
663    #[test]
664    fn default_config_with_collaborators() {
665        let config = default_config("proj", "desc", "main", &["alice"]);
666        let parsed: toml::Value = toml::from_str(&config).unwrap();
667        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
668        assert_eq!(collaborators.len(), 1);
669        assert_eq!(collaborators[0].as_str().unwrap(), "alice");
670    }
671
672    #[test]
673    fn default_config_empty_collaborators() {
674        let config = default_config("proj", "desc", "main", &[]);
675        let parsed: toml::Value = toml::from_str(&config).unwrap();
676        let collaborators = parsed["project"]["collaborators"].as_array().unwrap();
677        assert!(collaborators.is_empty());
678    }
679
680    #[test]
681    fn write_default_creates_new_file() {
682        let tmp = TempDir::new().unwrap();
683        let path = tmp.path().join("test.toml");
684        let mut msgs = Vec::new();
685        let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
686        assert!(matches!(action, WriteAction::Created));
687        assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
688    }
689
690    #[test]
691    fn write_default_unchanged_when_identical() {
692        let tmp = TempDir::new().unwrap();
693        let path = tmp.path().join("test.toml");
694        std::fs::write(&path, "content").unwrap();
695        let mut msgs = Vec::new();
696        let action = write_default(&path, "content", "test.toml", &mut msgs).unwrap();
697        assert!(matches!(action, WriteAction::Unchanged));
698    }
699
700    #[test]
701    fn write_default_non_tty_writes_init_when_differs() {
702        // In test context stdin is not a terminal, so this exercises
703        // the non-interactive path: write .init copy.
704        let tmp = TempDir::new().unwrap();
705        let path = tmp.path().join("test.toml");
706        std::fs::write(&path, "modified").unwrap();
707        let mut msgs = Vec::new();
708        let action = write_default(&path, "default", "test.toml", &mut msgs).unwrap();
709        assert!(matches!(action, WriteAction::InitWritten));
710        assert_eq!(std::fs::read_to_string(&path).unwrap(), "modified");
711        assert_eq!(
712            std::fs::read_to_string(tmp.path().join("test.toml.init")).unwrap(),
713            "default"
714        );
715    }
716
717    #[test]
718    fn init_path_for_preserves_extension() {
719        let p = std::path::Path::new("/a/b/workflow.toml");
720        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/workflow.toml.init"));
721
722        let p = std::path::Path::new("/a/b/agents.md");
723        assert_eq!(init_path_for(p), std::path::PathBuf::from("/a/b/agents.md.init"));
724    }
725
726    #[test]
727    fn setup_writes_init_files_when_content_differs() {
728        let tmp = TempDir::new().unwrap();
729        git_init(tmp.path());
730        // First setup: creates all files
731        setup(tmp.path(), None, None, None).unwrap();
732
733        // Modify a file
734        let workflow = tmp.path().join(".apm/workflow.toml");
735        std::fs::write(&workflow, "# custom workflow\n").unwrap();
736
737        // Second setup (non-tty): should write .init copy
738        setup(tmp.path(), None, None, None).unwrap();
739        assert!(tmp.path().join(".apm/workflow.toml.init").exists());
740        // Original should be untouched
741        assert_eq!(std::fs::read_to_string(&workflow).unwrap(), "# custom workflow\n");
742        // .init should have the default content
743        let init_content = std::fs::read_to_string(tmp.path().join(".apm/workflow.toml.init")).unwrap();
744        assert_eq!(init_content, default_workflow_toml());
745    }
746
747    #[test]
748    fn setup_writes_config_init_when_modified() {
749        let tmp = TempDir::new().unwrap();
750        git_init(tmp.path());
751        setup(tmp.path(), None, None, None).unwrap();
752
753        // Modify config.toml (add a custom section)
754        let config_path = tmp.path().join(".apm/config.toml");
755        let mut content = std::fs::read_to_string(&config_path).unwrap();
756        content.push_str("\n[custom]\nfoo = \"bar\"\n");
757        std::fs::write(&config_path, &content).unwrap();
758
759        // Second setup (non-tty): should write config.toml.init
760        setup(tmp.path(), None, None, None).unwrap();
761        assert!(tmp.path().join(".apm/config.toml.init").exists());
762        // Original should be untouched
763        assert!(std::fs::read_to_string(&config_path).unwrap().contains("[custom]"));
764        // .init should be the default for this project's name/branch
765        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
766        assert!(!init_content.contains("[custom]"));
767        assert!(init_content.contains("[project]"));
768        assert!(init_content.contains("[workers]"));
769        // .init should carry the same collaborators as the live config
770        assert!(init_content.contains("collaborators = []"));
771    }
772
773    #[test]
774    fn setup_no_false_diff_when_collaborators_present() {
775        let tmp = TempDir::new().unwrap();
776        git_init(tmp.path());
777        // First run: create config with collaborators = ["alice"]
778        setup(tmp.path(), None, None, Some("alice")).unwrap();
779
780        // Re-run without username (simulates non-TTY subsequent call)
781        setup(tmp.path(), None, None, None).unwrap();
782
783        // Should NOT produce a false diff
784        assert!(!tmp.path().join(".apm/config.toml.init").exists());
785    }
786
787    #[test]
788    fn setup_config_init_collaborators_match_live() {
789        let tmp = TempDir::new().unwrap();
790        git_init(tmp.path());
791        setup(tmp.path(), None, None, Some("alice")).unwrap();
792
793        // Manually edit config to add a custom section (to trigger a real diff)
794        let config_path = tmp.path().join(".apm/config.toml");
795        let mut content = std::fs::read_to_string(&config_path).unwrap();
796        content.push_str("\n[custom]\nfoo = \"bar\"\n");
797        std::fs::write(&config_path, &content).unwrap();
798
799        setup(tmp.path(), None, None, None).unwrap();
800
801        // .init must exist (real diff)
802        assert!(tmp.path().join(".apm/config.toml.init").exists());
803        // .init should contain alice's collaborators, not an empty array
804        let init_content = std::fs::read_to_string(tmp.path().join(".apm/config.toml.init")).unwrap();
805        assert!(init_content.contains("\"alice\""), ".init must carry alice's collaborator entry");
806    }
807
808    #[test]
809    fn setup_no_false_diff_empty_collaborators() {
810        let tmp = TempDir::new().unwrap();
811        git_init(tmp.path());
812        // First run: no username → collaborators = []
813        setup(tmp.path(), None, None, None).unwrap();
814        // Re-run: should still be idempotent
815        setup(tmp.path(), None, None, None).unwrap();
816        assert!(!tmp.path().join(".apm/config.toml.init").exists());
817    }
818
819    #[test]
820    fn default_workflow_toml_is_valid() {
821        use crate::config::{SatisfiesDeps, WorkflowFile};
822
823        let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap();
824        let states = &parsed.workflow.states;
825
826        let ids: Vec<&str> = states.iter().map(|s| s.id.as_str()).collect();
827        assert_eq!(
828            ids,
829            ["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "blocked", "implemented", "merge_failed", "closed"]
830        );
831
832        for id in ["groomed", "ammend"] {
833            let s = states.iter().find(|s| s.id == id).unwrap();
834            assert!(s.dep_requires.is_some(), "state {id} should have dep_requires");
835        }
836
837        for id in ["specd", "ammend", "ready", "in_progress", "implemented"] {
838            let s = states.iter().find(|s| s.id == id).unwrap();
839            assert_ne!(s.satisfies_deps, SatisfiesDeps::Bool(false), "state {id} should have satisfies_deps");
840        }
841    }
842
843    #[test]
844    fn default_ticket_toml_is_valid() {
845        use crate::config::TicketFile;
846
847        let parsed: TicketFile = toml::from_str(default_ticket_toml()).unwrap();
848        let sections = &parsed.ticket.sections;
849
850        for name in ["Problem", "Acceptance criteria", "Out of scope", "Approach"] {
851            let s = sections.iter().find(|s| s.name == name).unwrap();
852            assert!(s.required, "section '{name}' should be required");
853        }
854    }
855
856    #[test]
857    fn default_config_has_in_repo_worktrees_dir() {
858        let config = default_config("myproj", "desc", "main", &[]);
859        assert!(
860            config.contains("dir = \"worktrees\""),
861            "default config should use in-repo worktrees dir: {config}"
862        );
863        assert!(
864            !config.contains("--worktrees"),
865            "default config must not reference the old external layout: {config}"
866        );
867    }
868
869    #[test]
870    fn setup_gitignore_includes_worktrees_pattern() {
871        let tmp = TempDir::new().unwrap();
872        git_init(tmp.path());
873        setup(tmp.path(), None, None, None).unwrap();
874        let contents = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
875        assert!(contents.contains("/worktrees/"), ".gitignore must contain /worktrees/");
876        assert!(contents.contains("# apm worktrees"), ".gitignore must contain the apm worktrees comment");
877    }
878
879    #[test]
880    fn ensure_gitignore_worktrees_idempotent() {
881        let tmp = TempDir::new().unwrap();
882        let path = tmp.path().join(".gitignore");
883        let mut msgs = Vec::new();
884        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
885        let before = std::fs::read_to_string(&path).unwrap();
886        ensure_gitignore(&path, Some("/worktrees/"), &mut msgs).unwrap();
887        let after = std::fs::read_to_string(&path).unwrap();
888        assert_eq!(before, after, "second ensure_gitignore must not duplicate /worktrees/ entry");
889        let count = before.matches("/worktrees/").count();
890        assert_eq!(count, 1, "/worktrees/ must appear exactly once, found {count}");
891    }
892
893    #[test]
894    fn setup_creates_worktrees_dir_inside_repo() {
895        let tmp = TempDir::new().unwrap();
896        git_init(tmp.path());
897        setup(tmp.path(), None, None, None).unwrap();
898        assert!(
899            tmp.path().join("worktrees").exists(),
900            "worktrees dir should be created inside the repo"
901        );
902    }
903
904    #[test]
905    fn worktree_gitignore_pattern_simple() {
906        assert_eq!(
907            worktree_gitignore_pattern(std::path::Path::new("worktrees")),
908            Some("/worktrees/".to_string())
909        );
910    }
911
912    #[test]
913    fn worktree_gitignore_pattern_hidden_dir() {
914        assert_eq!(
915            worktree_gitignore_pattern(std::path::Path::new(".apm--worktrees")),
916            Some("/.apm--worktrees/".to_string())
917        );
918    }
919
920    #[test]
921    fn worktree_gitignore_pattern_nested() {
922        assert_eq!(
923            worktree_gitignore_pattern(std::path::Path::new("build/wt")),
924            Some("/build/wt/".to_string())
925        );
926    }
927
928    #[test]
929    fn worktree_gitignore_pattern_absolute_is_none() {
930        assert_eq!(
931            worktree_gitignore_pattern(std::path::Path::new("/abs/path")),
932            None
933        );
934    }
935
936    #[test]
937    fn worktree_gitignore_pattern_parent_relative_is_none() {
938        assert_eq!(
939            worktree_gitignore_pattern(std::path::Path::new("../external")),
940            None
941        );
942    }
943}