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