Skip to main content

git_paw/
init.rs

1//! Project initialization.
2//!
3//! Implements `git paw init` — creates `.git-paw/` directory, generates
4//! default config, and manages `.gitignore`.
5
6use std::fmt::Write as _;
7use std::fs;
8use std::io::IsTerminal;
9use std::path::Path;
10
11use dialoguer::{Confirm, Input};
12
13use crate::config;
14use crate::error::PawError;
15use crate::git;
16
17/// Gitignore entries managed by init. `.git-paw/tmp/` is the repo-local
18/// scratch dir (isolated verify worktrees, self-test sessions) — preferred
19/// over OS temp because it is OS-independent, so it must never be committed.
20const GITIGNORE_ENTRIES: &[&str] = &[
21    ".git-paw/logs/",
22    ".git-paw/tmp/",
23    ".git-paw/worktrees/",
24    ".git-paw/session-summary.md",
25    ".git-paw/session-learnings.md",
26];
27
28/// Bundled supervisor-sweep helper script, embedded at compile time and
29/// written to `<repo>/.git-paw/scripts/sweep.sh` by [`run_init`].
30const SWEEP_SCRIPT: &str = include_str!("../assets/scripts/sweep.sh");
31
32/// Bundled agent-side broker helper script, embedded at compile time and
33/// written to `<repo>/.git-paw/scripts/broker.sh` by [`run_init`]. The
34/// analogue of [`SWEEP_SCRIPT`] for the coding-agent side — it wraps every
35/// agent→broker `curl` so the boot block calls one stable script path
36/// instead of inlining raw `curl` commands.
37const BROKER_SCRIPT: &str = include_str!("../assets/scripts/broker.sh");
38
39/// Runs the `git paw init` command.
40///
41/// Creates `.git-paw/` directory structure, generates a default config,
42/// installs the bundled `sweep.sh` supervisor helper at
43/// `<repo>/.git-paw/scripts/sweep.sh` (executable mode `0o755` on Unix),
44/// and manages `.gitignore`. The script is overwritten on every invocation
45/// so re-running `git paw init` picks up updates that ship with new
46/// versions of the binary. Idempotent for the other side effects —
47/// running twice produces identical results for the directory tree,
48/// `config.toml`, and `.gitignore`.
49pub fn run_init() -> Result<(), PawError> {
50    let cwd = std::env::current_dir()
51        .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
52    let repo_root = git::validate_repo(&cwd)?;
53
54    let paw_dir = repo_root.join(".git-paw");
55    let logs_dir = paw_dir.join("logs");
56    let tmp_dir = paw_dir.join("tmp");
57    let scripts_dir = paw_dir.join("scripts");
58    let config_path = paw_dir.join("config.toml");
59
60    // 1. Create .git-paw/ directory
61    let created_dir = create_dir_if_missing(&paw_dir)?;
62    if created_dir {
63        println!("  Created .git-paw/");
64    }
65
66    // 2. Create .git-paw/logs/ directory
67    let created_logs = create_dir_if_missing(&logs_dir)?;
68    if created_logs {
69        println!("  Created .git-paw/logs/");
70    }
71
72    // 2b. Create .git-paw/tmp/ — repo-local scratch for isolated verify
73    //     worktrees and self-test sessions (preferred over OS temp).
74    let created_tmp = create_dir_if_missing(&tmp_dir)?;
75    if created_tmp {
76        println!("  Created .git-paw/tmp/");
77    }
78
79    // 3. Create .git-paw/scripts/ directory and install the bundled helpers
80    //    (sweep.sh for the supervisor, broker.sh for the coding agents).
81    let created_scripts = create_dir_if_missing(&scripts_dir)?;
82    if created_scripts {
83        println!("  Created .git-paw/scripts/");
84    }
85    let sweep_path = scripts_dir.join("sweep.sh");
86    let sweep_existed = sweep_path.exists();
87    install_script(&sweep_path, SWEEP_SCRIPT)?;
88    if sweep_existed {
89        println!("  Updated .git-paw/scripts/sweep.sh");
90    } else {
91        println!("  Created .git-paw/scripts/sweep.sh");
92    }
93    let broker_path = scripts_dir.join("broker.sh");
94    let broker_existed = broker_path.exists();
95    install_script(&broker_path, BROKER_SCRIPT)?;
96    if broker_existed {
97        println!("  Updated .git-paw/scripts/broker.sh");
98    } else {
99        println!("  Created .git-paw/scripts/broker.sh");
100    }
101
102    // 4. Generate or migrate config. For a fresh config, prompt for supervisor
103    //    preferences and auto-detect `.specify/` to pre-fill `[specs]`. For an
104    //    existing config without a [supervisor] section, append one (prompting
105    //    if stdin is interactive). Init never mutates existing sections — only
106    //    appends missing ones.
107    let (created_config, migrated_config) = if config_path.exists() {
108        let migrated = migrate_existing_config(&config_path)?;
109        (false, migrated)
110    } else {
111        let supervisor_section = prompt_supervisor_section()?;
112        let specs_section = detect_speckit_section(&repo_root);
113        write_config_if_missing(
114            &config_path,
115            Some(&supervisor_section),
116            specs_section.as_deref(),
117        )?;
118        (true, false)
119    };
120    if created_config {
121        println!("  Created .git-paw/config.toml");
122    } else if migrated_config {
123        println!("  Updated .git-paw/config.toml (added missing sections)");
124    }
125
126    // 5. Manage .gitignore
127    let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
128    if updated_gitignore {
129        println!("  Updated .gitignore");
130    }
131
132    if !created_dir
133        && !created_logs
134        && !created_tmp
135        && !created_config
136        && !migrated_config
137        && !updated_gitignore
138    {
139        println!("Already initialized. Nothing to do.");
140    } else {
141        println!("Initialized git-paw.");
142    }
143
144    Ok(())
145}
146
147/// Writes a bundled helper script `content` to `path` and marks it
148/// executable (mode `0o755` on Unix). Overwrites any existing file at `path`
149/// (the scripts are treated as binary-managed content — users with local
150/// edits SHALL back the file up before re-running `git paw init`). Shared by
151/// the `sweep.sh` and `broker.sh` installers.
152fn install_script(path: &Path, content: &str) -> Result<(), PawError> {
153    fs::write(path, content)
154        .map_err(|e| PawError::InitError(format!("failed to write '{}': {e}", path.display())))?;
155
156    #[cfg(unix)]
157    {
158        use std::os::unix::fs::PermissionsExt;
159        let mut perms = fs::metadata(path)
160            .map_err(|e| PawError::InitError(format!("failed to stat '{}': {e}", path.display())))?
161            .permissions();
162        perms.set_mode(0o755);
163        fs::set_permissions(path, perms).map_err(|e| {
164            PawError::InitError(format!(
165                "failed to set executable bit on '{}': {e}",
166                path.display()
167            ))
168        })?;
169    }
170
171    Ok(())
172}
173
174/// Creates a directory if it doesn't exist. Returns `true` if created.
175fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
176    if path.is_dir() {
177        return Ok(false);
178    }
179    fs::create_dir_all(path)
180        .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
181    Ok(true)
182}
183
184/// Appends any missing sections to an existing `config.toml`. Returns `true`
185/// if the file was modified. Does not touch any existing field — this is the
186/// safe upgrade path for new config sections added across versions.
187fn migrate_existing_config(path: &Path) -> Result<bool, PawError> {
188    let existing = fs::read_to_string(path)
189        .map_err(|e| PawError::InitError(format!("failed to read config: {e}")))?;
190
191    let mut appended = String::new();
192
193    // [supervisor] — the only section currently managed by migration. We
194    // detect presence with a simple line-based scan rather than parsing TOML
195    // so we don't lose comments or reorder fields on round-trip.
196    if !has_section(&existing, "supervisor") {
197        let section = prompt_supervisor_section()?;
198        appended.push_str(&section);
199    }
200
201    if appended.is_empty() {
202        return Ok(false);
203    }
204
205    let mut new_content = existing;
206    if !new_content.ends_with('\n') {
207        new_content.push('\n');
208    }
209    new_content.push_str(&appended);
210
211    fs::write(path, new_content)
212        .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
213    Ok(true)
214}
215
216/// Returns `true` if a non-commented `[section]` header exists in `content`.
217fn has_section(content: &str, section: &str) -> bool {
218    let header = format!("[{section}]");
219    content.lines().any(|line| {
220        let trimmed = line.trim_start();
221        !trimmed.starts_with('#') && trimmed.trim_end() == header
222    })
223}
224
225/// Writes the default config if the file doesn't already exist. Returns `true` if written.
226///
227/// If `supervisor_section` is `Some`, it is appended to the generated config so
228/// the user's init-time choice is persisted.
229///
230/// If `specs_section` is `Some`, it is appended to the generated config. This
231/// is how Spec Kit auto-detection persists `[specs] type = "speckit"` at init
232/// time.
233fn write_config_if_missing(
234    path: &Path,
235    supervisor_section: Option<&str>,
236    specs_section: Option<&str>,
237) -> Result<bool, PawError> {
238    if path.exists() {
239        return Ok(false);
240    }
241    let mut content = config::generate_default_config();
242    if let Some(section) = supervisor_section {
243        content.push_str(section);
244    }
245    if let Some(section) = specs_section {
246        content.push_str(section);
247    }
248    fs::write(path, content)
249        .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
250    Ok(true)
251}
252
253/// Returns a TOML `[specs]` section for `speckit` if `.specify/specs/` is
254/// present at `repo_root`, otherwise `None`. The generated section locks the
255/// choice in the config so future runs do not depend on auto-detection.
256fn detect_speckit_section(repo_root: &Path) -> Option<String> {
257    let specify = repo_root.join(".specify");
258    if !specify.is_dir() || !specify.join("specs").is_dir() {
259        return None;
260    }
261    Some(
262        "\n[specs]\n\
263         type = \"speckit\"\n\
264         dir = \".specify/specs\"\n"
265            .to_string(),
266    )
267}
268
269/// Prompts the user for their supervisor preferences and returns a TOML
270/// `[supervisor]` section to append to the generated config.
271///
272/// If the user declines, an explicit `enabled = false` section is returned so
273/// that future `git paw start` calls do not re-prompt.
274fn prompt_supervisor_section() -> Result<String, PawError> {
275    // In non-interactive contexts (CI, tests, piped stdin) fall back to an
276    // explicit opt-out so init remains scriptable.
277    if !std::io::stdin().is_terminal() {
278        return Ok("\n[supervisor]\nenabled = false\n".to_string());
279    }
280
281    let enabled = Confirm::new()
282        .with_prompt("Enable supervisor mode by default?")
283        .default(false)
284        .interact()
285        .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
286
287    if !enabled {
288        return Ok("\n[supervisor]\nenabled = false\n".to_string());
289    }
290
291    let test_command: String = Input::new()
292        .with_prompt("Test command to run after each agent completes (e.g. 'just check', leave empty to skip)")
293        .allow_empty(true)
294        .interact_text()
295        .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
296
297    let mut section = String::from("\n[supervisor]\nenabled = true\n");
298    let trimmed = test_command.trim();
299    if !trimmed.is_empty() {
300        let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
301        writeln!(section, "test_command = \"{escaped}\"")
302            .map_err(|e| PawError::InitError(format!("format supervisor section: {e}")))?;
303    }
304    Ok(section)
305}
306
307/// Ensures `.gitignore` contains all managed entries. Returns `true` if modified.
308fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
309    let gitignore_path = repo_root.join(".gitignore");
310
311    let existing = match fs::read_to_string(&gitignore_path) {
312        Ok(content) => content,
313        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
314        Err(e) => {
315            return Err(PawError::InitError(format!(
316                "failed to read .gitignore: {e}"
317            )));
318        }
319    };
320
321    let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
322    let missing: Vec<&&str> = GITIGNORE_ENTRIES
323        .iter()
324        .filter(|e| !existing_lines.contains(**e))
325        .collect();
326
327    if missing.is_empty() {
328        return Ok(false);
329    }
330
331    let mut content = existing;
332    if !content.is_empty() && !content.ends_with('\n') {
333        content.push('\n');
334    }
335    for entry in missing {
336        content.push_str(entry);
337        content.push('\n');
338    }
339
340    fs::write(&gitignore_path, content)
341        .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
342
343    Ok(true)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use tempfile::TempDir;
350
351    fn setup_repo() -> TempDir {
352        let dir = TempDir::new().unwrap();
353        // Create a minimal .git dir so validate_repo-like checks work
354        fs::create_dir(dir.path().join(".git")).unwrap();
355        dir
356    }
357
358    // --- create_dir_if_missing ---
359
360    #[test]
361    fn creates_directory_when_missing() {
362        let dir = TempDir::new().unwrap();
363        let target = dir.path().join("new-dir");
364        assert!(create_dir_if_missing(&target).unwrap());
365        assert!(target.is_dir());
366    }
367
368    #[test]
369    fn skips_existing_directory() {
370        let dir = TempDir::new().unwrap();
371        let target = dir.path().join("existing");
372        fs::create_dir(&target).unwrap();
373        assert!(!create_dir_if_missing(&target).unwrap());
374    }
375
376    // --- write_config_if_missing ---
377
378    #[test]
379    fn writes_config_when_missing() {
380        let dir = TempDir::new().unwrap();
381        let config_path = dir.path().join("config.toml");
382        assert!(write_config_if_missing(&config_path, None, None).unwrap());
383        let content = fs::read_to_string(&config_path).unwrap();
384        assert!(content.contains("default_cli"));
385    }
386
387    #[test]
388    fn skips_existing_config() {
389        let dir = TempDir::new().unwrap();
390        let config_path = dir.path().join("config.toml");
391        fs::write(&config_path, "existing").unwrap();
392        assert!(!write_config_if_missing(&config_path, None, None).unwrap());
393        assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
394    }
395
396    #[test]
397    fn appends_supervisor_section_when_provided() {
398        let dir = TempDir::new().unwrap();
399        let config_path = dir.path().join("config.toml");
400        let section = "\n[supervisor]\nenabled = true\ntest_command = \"just check\"\n";
401        assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
402
403        let content = fs::read_to_string(&config_path).unwrap();
404        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
405        let supervisor = parsed.supervisor.unwrap();
406        assert!(supervisor.enabled);
407        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
408    }
409
410    #[test]
411    fn detect_speckit_section_returns_some_when_specify_present() {
412        let dir = setup_repo();
413        fs::create_dir_all(dir.path().join(".specify").join("specs")).unwrap();
414        let section = detect_speckit_section(dir.path()).expect("section");
415        assert!(section.contains("[specs]"));
416        assert!(section.contains("type = \"speckit\""));
417        assert!(section.contains("dir = \".specify/specs\""));
418    }
419
420    #[test]
421    fn detect_speckit_section_none_when_specify_missing() {
422        let dir = setup_repo();
423        assert!(detect_speckit_section(dir.path()).is_none());
424    }
425
426    #[test]
427    fn detect_speckit_section_none_when_specify_lacks_specs_subdir() {
428        let dir = setup_repo();
429        fs::create_dir_all(dir.path().join(".specify").join("memory")).unwrap();
430        assert!(detect_speckit_section(dir.path()).is_none());
431    }
432
433    #[test]
434    fn write_config_appends_specs_section_when_provided() {
435        let dir = TempDir::new().unwrap();
436        let config_path = dir.path().join("config.toml");
437        let specs_section = "\n[specs]\ntype = \"speckit\"\ndir = \".specify/specs\"\n";
438        assert!(write_config_if_missing(&config_path, None, Some(specs_section)).unwrap());
439
440        let content = fs::read_to_string(&config_path).unwrap();
441        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
442        let specs = parsed.specs.expect("specs section parsed");
443        assert_eq!(specs.spec_type.as_deref(), Some("speckit"));
444        assert_eq!(specs.dir.as_deref(), Some(".specify/specs"));
445    }
446
447    #[test]
448    fn appends_disabled_supervisor_section() {
449        let dir = TempDir::new().unwrap();
450        let config_path = dir.path().join("config.toml");
451        let section = "\n[supervisor]\nenabled = false\n";
452        assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
453
454        let content = fs::read_to_string(&config_path).unwrap();
455        let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
456        let supervisor = parsed.supervisor.unwrap();
457        assert!(!supervisor.enabled);
458    }
459
460    // --- ensure_gitignore_entry ---
461
462    #[test]
463    fn creates_gitignore_with_entry() {
464        let dir = setup_repo();
465        assert!(ensure_gitignore_entry(dir.path()).unwrap());
466        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
467        for entry in GITIGNORE_ENTRIES {
468            assert!(content.contains(entry), "missing {entry}");
469        }
470    }
471
472    #[test]
473    fn appends_to_existing_gitignore() {
474        let dir = setup_repo();
475        fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
476        assert!(ensure_gitignore_entry(dir.path()).unwrap());
477        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
478        assert!(content.contains("node_modules/"));
479        for entry in GITIGNORE_ENTRIES {
480            assert!(content.contains(entry), "missing {entry}");
481        }
482    }
483
484    #[test]
485    fn appends_newline_if_missing() {
486        let dir = setup_repo();
487        fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
488        assert!(ensure_gitignore_entry(dir.path()).unwrap());
489        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
490        assert!(content.contains("node_modules/\n"));
491        for entry in GITIGNORE_ENTRIES {
492            assert!(content.contains(entry), "missing {entry}");
493        }
494    }
495
496    #[test]
497    fn skips_when_all_entries_already_present() {
498        let dir = setup_repo();
499        let mut lines = String::from("node_modules/\n");
500        for entry in GITIGNORE_ENTRIES {
501            lines.push_str(entry);
502            lines.push('\n');
503        }
504        fs::write(dir.path().join(".gitignore"), lines).unwrap();
505        assert!(!ensure_gitignore_entry(dir.path()).unwrap());
506    }
507
508    #[test]
509    fn session_summary_added_alongside_logs() {
510        let dir = setup_repo();
511        fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
512        assert!(ensure_gitignore_entry(dir.path()).unwrap());
513        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
514        assert!(content.contains(".git-paw/session-summary.md"));
515        assert_eq!(content.matches(".git-paw/logs/").count(), 1);
516    }
517
518    #[test]
519    fn session_learnings_added_to_gitignore() {
520        // The learnings aggregator writes .git-paw/session-learnings.md as
521        // per-session runtime output; it must never be committed (an agent's
522        // `git add -A` otherwise sweeps it into a PR).
523        let dir = setup_repo();
524        fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
525        assert!(ensure_gitignore_entry(dir.path()).unwrap());
526        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
527        assert!(
528            content.contains(".git-paw/session-learnings.md"),
529            "init must gitignore the per-session .git-paw/session-learnings.md output"
530        );
531    }
532
533    #[test]
534    fn repo_local_tmp_added_to_gitignore_and_not_duplicated() {
535        // The repo-local scratch dir must be ignored so verify worktrees /
536        // self-test sessions are never committed in the consuming repo.
537        let dir = setup_repo();
538        // Pre-seed only logs/ — tmp/ must be appended.
539        fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
540        assert!(ensure_gitignore_entry(dir.path()).unwrap());
541        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
542        assert!(
543            content.contains(".git-paw/tmp/"),
544            "init must gitignore the repo-local .git-paw/tmp/ scratch dir"
545        );
546        // Idempotent: a second pass adds nothing and keeps a single entry.
547        assert!(!ensure_gitignore_entry(dir.path()).unwrap());
548        let content2 = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
549        assert_eq!(
550            content2.matches(".git-paw/tmp/").count(),
551            1,
552            ".git-paw/tmp/ must appear exactly once after repeated init"
553        );
554    }
555
556    #[test]
557    fn worktrees_dir_added_to_gitignore_and_not_duplicated() {
558        // Child-placement worktrees live under .git-paw/worktrees/; that path
559        // must be ignored so in-repo worktrees are never staged.
560        let dir = setup_repo();
561        // Pre-seed only logs/ — worktrees/ must be appended.
562        fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
563        assert!(ensure_gitignore_entry(dir.path()).unwrap());
564        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
565        assert!(
566            content.contains(".git-paw/worktrees/"),
567            "init must gitignore the in-repo .git-paw/worktrees/ dir"
568        );
569        // Idempotent: a second pass adds nothing and keeps a single entry.
570        assert!(!ensure_gitignore_entry(dir.path()).unwrap());
571        let content2 = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
572        assert_eq!(
573            content2.matches(".git-paw/worktrees/").count(),
574            1,
575            ".git-paw/worktrees/ must appear exactly once after repeated init"
576        );
577    }
578
579    // --- migrate_existing_config ---
580
581    #[test]
582    fn has_section_detects_active_header() {
583        assert!(has_section("[supervisor]\nenabled = true\n", "supervisor"));
584        assert!(!has_section("# [supervisor]\n", "supervisor"));
585        assert!(!has_section("[broker]\n", "supervisor"));
586    }
587
588    /// Migration does not touch existing sections. A config already containing
589    /// `[supervisor]` plus a custom `[broker]` port must round-trip with both
590    /// sections and the custom port intact.
591    #[test]
592    fn migrate_preserves_existing_supervisor_and_custom_broker_port() {
593        let dir = TempDir::new().unwrap();
594        let config_path = dir.path().join("config.toml");
595        let initial = r#"[broker]
596enabled = true
597port = 12345
598
599[supervisor]
600enabled = true
601cli = "echo"
602"#;
603        fs::write(&config_path, initial).unwrap();
604
605        let modified = migrate_existing_config(&config_path).unwrap();
606        assert!(
607            !modified,
608            "migrate must be a no-op when [supervisor] already exists"
609        );
610
611        let after = fs::read_to_string(&config_path).unwrap();
612        assert!(
613            after.contains("port = 12345"),
614            "custom broker port must be preserved verbatim; got:\n{after}"
615        );
616        assert!(
617            after.contains("[supervisor]"),
618            "supervisor header must be preserved; got:\n{after}"
619        );
620        assert!(
621            after.contains("cli = \"echo\""),
622            "supervisor cli must be preserved; got:\n{after}"
623        );
624
625        // The TOML must still parse to a config with the expected fields.
626        let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
627        let supervisor = parsed.supervisor.expect("supervisor present");
628        assert!(supervisor.enabled);
629        assert_eq!(supervisor.cli.as_deref(), Some("echo"));
630        assert_eq!(parsed.broker.port, 12345);
631    }
632
633    /// When `[supervisor]` is missing, migrate appends a section. Stdin in
634    /// tests is non-interactive, so the appended section is the explicit
635    /// opt-out (`enabled = false`). The pre-existing `[broker]` section and
636    /// its custom port must remain untouched.
637    #[test]
638    fn migrate_appends_supervisor_section_when_missing_and_keeps_broker_port() {
639        let dir = TempDir::new().unwrap();
640        let config_path = dir.path().join("config.toml");
641        let initial = "[broker]\nenabled = true\nport = 9119\n";
642        fs::write(&config_path, initial).unwrap();
643
644        let modified = migrate_existing_config(&config_path).unwrap();
645        assert!(
646            modified,
647            "migrate must report that the file was modified when appending"
648        );
649
650        let after = fs::read_to_string(&config_path).unwrap();
651        // Original section preserved.
652        assert!(
653            after.contains("port = 9119"),
654            "broker port must survive migration; got:\n{after}"
655        );
656        // Section appended.
657        assert!(
658            after.contains("[supervisor]"),
659            "supervisor section must be appended; got:\n{after}"
660        );
661
662        let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
663        let supervisor = parsed.supervisor.expect("supervisor present");
664        assert!(
665            !supervisor.enabled,
666            "non-interactive migrate should opt out by default"
667        );
668        assert_eq!(parsed.broker.port, 9119);
669    }
670
671    /// Running migrate twice must produce identical content — the second run
672    /// has nothing to do.
673    #[test]
674    fn migrate_existing_config_is_idempotent() {
675        let dir = TempDir::new().unwrap();
676        let config_path = dir.path().join("config.toml");
677        fs::write(&config_path, "[broker]\nenabled = true\nport = 9119\n").unwrap();
678
679        migrate_existing_config(&config_path).unwrap();
680        let first = fs::read_to_string(&config_path).unwrap();
681        let modified = migrate_existing_config(&config_path).unwrap();
682        let second = fs::read_to_string(&config_path).unwrap();
683
684        assert!(!modified, "second migrate must be a no-op");
685        assert_eq!(first, second);
686    }
687
688    /// Bug F (v0-5-0-audit-cleanup §9d) — a config with an UNCOMMENTED
689    /// `[supervisor]` block must survive migrate without growing a
690    /// duplicate header. `has_section` is comment-aware: it only
691    /// matches active headers, so the uncommented user block is
692    /// detected and no stanza is appended. The file MUST still parse as
693    /// valid TOML afterwards (no `duplicate key` error).
694    #[test]
695    fn migrate_against_uncommented_supervisor_does_not_create_duplicate() {
696        let dir = TempDir::new().unwrap();
697        let config_path = dir.path().join("config.toml");
698        let initial = r#"# user-authored config
699branch_prefix = "feat/"
700
701[supervisor]
702enabled = true
703cli = "claude-oss"
704test_command = "just check"
705"#;
706        fs::write(&config_path, initial).unwrap();
707
708        let modified = migrate_existing_config(&config_path).unwrap();
709        assert!(
710            !modified,
711            "migrate must be a no-op when an uncommented [supervisor] block already exists"
712        );
713
714        let after = fs::read_to_string(&config_path).unwrap();
715        let header_count = after.lines().filter(|l| l.trim() == "[supervisor]").count();
716        assert_eq!(
717            header_count, 1,
718            "exactly one [supervisor] header must exist; found {header_count} in:\n{after}"
719        );
720
721        // Crucially, the file must parse without a duplicate-key error.
722        let parsed: crate::config::PawConfig = toml::from_str(&after).expect(
723            "config with uncommented [supervisor] must parse cleanly after migrate (no duplicate key)",
724        );
725        let supervisor = parsed.supervisor.expect("supervisor present");
726        assert!(supervisor.enabled);
727        assert_eq!(supervisor.cli.as_deref(), Some("claude-oss"));
728        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
729    }
730
731    /// 9d.7 sibling — when a user writes `branch_prefix = "feat/"` only
732    /// (no sections), running migrate appends the disabled
733    /// `[supervisor]` opt-out and preserves the user's `branch_prefix`.
734    /// The file parses as valid TOML.
735    ///
736    /// NOTE: the wider variant of 9d.7 (also appending commented
737    /// stanzas for `[broker]`, `[dashboard]`, etc.) is intentionally
738    /// deferred — it is a feature addition (richer migration), not a
739    /// bug fix. The current scope of `migrate_existing_config` is
740    /// limited to the `[supervisor]` section per the existing tests in
741    /// this module.
742    #[test]
743    fn migrate_against_branch_prefix_only_preserves_user_field() {
744        let dir = TempDir::new().unwrap();
745        let config_path = dir.path().join("config.toml");
746        fs::write(&config_path, "branch_prefix = \"feat/\"\n").unwrap();
747
748        let modified = migrate_existing_config(&config_path).unwrap();
749        assert!(
750            modified,
751            "migrate must append the missing [supervisor] section"
752        );
753
754        let after = fs::read_to_string(&config_path).unwrap();
755        assert!(
756            after.contains("branch_prefix = \"feat/\""),
757            "user branch_prefix must be preserved verbatim; got:\n{after}"
758        );
759        assert!(
760            after.contains("[supervisor]"),
761            "supervisor section must be appended; got:\n{after}"
762        );
763
764        // Most importantly: the result parses as valid TOML.
765        let parsed: crate::config::PawConfig = toml::from_str(&after)
766            .expect("config with branch_prefix + appended supervisor must parse cleanly");
767        assert_eq!(parsed.branch_prefix.as_deref(), Some("feat/"));
768    }
769
770    // --- Idempotency ---
771
772    #[test]
773    fn idempotent_gitignore() {
774        let dir = setup_repo();
775        ensure_gitignore_entry(dir.path()).unwrap();
776        let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
777        ensure_gitignore_entry(dir.path()).unwrap();
778        let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
779        assert_eq!(first, second);
780    }
781}