1use 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
17const GITIGNORE_ENTRIES: &[&str] = &[
21 ".git-paw/logs/",
22 ".git-paw/tmp/",
23 ".git-paw/session-summary.md",
24];
25
26const SWEEP_SCRIPT: &str = include_str!("../assets/scripts/sweep.sh");
29
30pub 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 let created_dir = create_dir_if_missing(&paw_dir)?;
53 if created_dir {
54 println!(" Created .git-paw/");
55 }
56
57 let created_logs = create_dir_if_missing(&logs_dir)?;
59 if created_logs {
60 println!(" Created .git-paw/logs/");
61 }
62
63 let created_tmp = create_dir_if_missing(&tmp_dir)?;
66 if created_tmp {
67 println!(" Created .git-paw/tmp/");
68 }
69
70 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 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 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
129fn 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
155fn 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
165fn 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 if !has_section(&existing, "supervisor") {
178 let section = prompt_supervisor_section()?;
179 appended.push_str(§ion);
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
197fn 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
206fn 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
234fn 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
250fn prompt_supervisor_section() -> Result<String, PawError> {
256 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
288fn 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 fs::create_dir(dir.path().join(".git")).unwrap();
336 dir
337 }
338
339 #[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 #[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 #[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 let dir = setup_repo();
504 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 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 #[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 #[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 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 #[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 assert!(
596 after.contains("port = 9119"),
597 "broker port must survive migration; got:\n{after}"
598 );
599 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 #[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 #[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 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 #[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 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 #[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}