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