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] = &[".git-paw/logs/", ".git-paw/session-summary.md"];
19
20const SWEEP_SCRIPT: &str = include_str!("../assets/scripts/sweep.sh");
23
24pub fn run_init() -> Result<(), PawError> {
35 let cwd = std::env::current_dir()
36 .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
37 let repo_root = git::validate_repo(&cwd)?;
38
39 let paw_dir = repo_root.join(".git-paw");
40 let logs_dir = paw_dir.join("logs");
41 let scripts_dir = paw_dir.join("scripts");
42 let config_path = paw_dir.join("config.toml");
43
44 let created_dir = create_dir_if_missing(&paw_dir)?;
46 if created_dir {
47 println!(" Created .git-paw/");
48 }
49
50 let created_logs = create_dir_if_missing(&logs_dir)?;
52 if created_logs {
53 println!(" Created .git-paw/logs/");
54 }
55
56 let created_scripts = create_dir_if_missing(&scripts_dir)?;
58 if created_scripts {
59 println!(" Created .git-paw/scripts/");
60 }
61 let sweep_path = scripts_dir.join("sweep.sh");
62 let sweep_existed = sweep_path.exists();
63 install_sweep_script(&sweep_path)?;
64 if sweep_existed {
65 println!(" Updated .git-paw/scripts/sweep.sh");
66 } else {
67 println!(" Created .git-paw/scripts/sweep.sh");
68 }
69
70 let (created_config, migrated_config) = if config_path.exists() {
76 let migrated = migrate_existing_config(&config_path)?;
77 (false, migrated)
78 } else {
79 let supervisor_section = prompt_supervisor_section()?;
80 let specs_section = detect_speckit_section(&repo_root);
81 write_config_if_missing(
82 &config_path,
83 Some(&supervisor_section),
84 specs_section.as_deref(),
85 )?;
86 (true, false)
87 };
88 if created_config {
89 println!(" Created .git-paw/config.toml");
90 } else if migrated_config {
91 println!(" Updated .git-paw/config.toml (added missing sections)");
92 }
93
94 let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
96 if updated_gitignore {
97 println!(" Updated .gitignore");
98 }
99
100 if !created_dir && !created_logs && !created_config && !migrated_config && !updated_gitignore {
101 println!("Already initialized. Nothing to do.");
102 } else {
103 println!("Initialized git-paw.");
104 }
105
106 Ok(())
107}
108
109fn install_sweep_script(path: &Path) -> Result<(), PawError> {
114 fs::write(path, SWEEP_SCRIPT)
115 .map_err(|e| PawError::InitError(format!("failed to write '{}': {e}", path.display())))?;
116
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::PermissionsExt;
120 let mut perms = fs::metadata(path)
121 .map_err(|e| PawError::InitError(format!("failed to stat '{}': {e}", path.display())))?
122 .permissions();
123 perms.set_mode(0o755);
124 fs::set_permissions(path, perms).map_err(|e| {
125 PawError::InitError(format!(
126 "failed to set executable bit on '{}': {e}",
127 path.display()
128 ))
129 })?;
130 }
131
132 Ok(())
133}
134
135fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
137 if path.is_dir() {
138 return Ok(false);
139 }
140 fs::create_dir_all(path)
141 .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
142 Ok(true)
143}
144
145fn migrate_existing_config(path: &Path) -> Result<bool, PawError> {
149 let existing = fs::read_to_string(path)
150 .map_err(|e| PawError::InitError(format!("failed to read config: {e}")))?;
151
152 let mut appended = String::new();
153
154 if !has_section(&existing, "supervisor") {
158 let section = prompt_supervisor_section()?;
159 appended.push_str(§ion);
160 }
161
162 if appended.is_empty() {
163 return Ok(false);
164 }
165
166 let mut new_content = existing;
167 if !new_content.ends_with('\n') {
168 new_content.push('\n');
169 }
170 new_content.push_str(&appended);
171
172 fs::write(path, new_content)
173 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
174 Ok(true)
175}
176
177fn has_section(content: &str, section: &str) -> bool {
179 let header = format!("[{section}]");
180 content.lines().any(|line| {
181 let trimmed = line.trim_start();
182 !trimmed.starts_with('#') && trimmed.trim_end() == header
183 })
184}
185
186fn write_config_if_missing(
195 path: &Path,
196 supervisor_section: Option<&str>,
197 specs_section: Option<&str>,
198) -> Result<bool, PawError> {
199 if path.exists() {
200 return Ok(false);
201 }
202 let mut content = config::generate_default_config();
203 if let Some(section) = supervisor_section {
204 content.push_str(section);
205 }
206 if let Some(section) = specs_section {
207 content.push_str(section);
208 }
209 fs::write(path, content)
210 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
211 Ok(true)
212}
213
214fn detect_speckit_section(repo_root: &Path) -> Option<String> {
218 let specify = repo_root.join(".specify");
219 if !specify.is_dir() || !specify.join("specs").is_dir() {
220 return None;
221 }
222 Some(
223 "\n[specs]\n\
224 type = \"speckit\"\n\
225 dir = \".specify/specs\"\n"
226 .to_string(),
227 )
228}
229
230fn prompt_supervisor_section() -> Result<String, PawError> {
236 if !std::io::stdin().is_terminal() {
239 return Ok("\n[supervisor]\nenabled = false\n".to_string());
240 }
241
242 let enabled = Confirm::new()
243 .with_prompt("Enable supervisor mode by default?")
244 .default(false)
245 .interact()
246 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
247
248 if !enabled {
249 return Ok("\n[supervisor]\nenabled = false\n".to_string());
250 }
251
252 let test_command: String = Input::new()
253 .with_prompt("Test command to run after each agent completes (e.g. 'just check', leave empty to skip)")
254 .allow_empty(true)
255 .interact_text()
256 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
257
258 let mut section = String::from("\n[supervisor]\nenabled = true\n");
259 let trimmed = test_command.trim();
260 if !trimmed.is_empty() {
261 let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
262 writeln!(section, "test_command = \"{escaped}\"")
263 .map_err(|e| PawError::InitError(format!("format supervisor section: {e}")))?;
264 }
265 Ok(section)
266}
267
268fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
270 let gitignore_path = repo_root.join(".gitignore");
271
272 let existing = match fs::read_to_string(&gitignore_path) {
273 Ok(content) => content,
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
275 Err(e) => {
276 return Err(PawError::InitError(format!(
277 "failed to read .gitignore: {e}"
278 )));
279 }
280 };
281
282 let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
283 let missing: Vec<&&str> = GITIGNORE_ENTRIES
284 .iter()
285 .filter(|e| !existing_lines.contains(**e))
286 .collect();
287
288 if missing.is_empty() {
289 return Ok(false);
290 }
291
292 let mut content = existing;
293 if !content.is_empty() && !content.ends_with('\n') {
294 content.push('\n');
295 }
296 for entry in missing {
297 content.push_str(entry);
298 content.push('\n');
299 }
300
301 fs::write(&gitignore_path, content)
302 .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
303
304 Ok(true)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use tempfile::TempDir;
311
312 fn setup_repo() -> TempDir {
313 let dir = TempDir::new().unwrap();
314 fs::create_dir(dir.path().join(".git")).unwrap();
316 dir
317 }
318
319 #[test]
322 fn creates_directory_when_missing() {
323 let dir = TempDir::new().unwrap();
324 let target = dir.path().join("new-dir");
325 assert!(create_dir_if_missing(&target).unwrap());
326 assert!(target.is_dir());
327 }
328
329 #[test]
330 fn skips_existing_directory() {
331 let dir = TempDir::new().unwrap();
332 let target = dir.path().join("existing");
333 fs::create_dir(&target).unwrap();
334 assert!(!create_dir_if_missing(&target).unwrap());
335 }
336
337 #[test]
340 fn writes_config_when_missing() {
341 let dir = TempDir::new().unwrap();
342 let config_path = dir.path().join("config.toml");
343 assert!(write_config_if_missing(&config_path, None, None).unwrap());
344 let content = fs::read_to_string(&config_path).unwrap();
345 assert!(content.contains("default_cli"));
346 }
347
348 #[test]
349 fn skips_existing_config() {
350 let dir = TempDir::new().unwrap();
351 let config_path = dir.path().join("config.toml");
352 fs::write(&config_path, "existing").unwrap();
353 assert!(!write_config_if_missing(&config_path, None, None).unwrap());
354 assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
355 }
356
357 #[test]
358 fn appends_supervisor_section_when_provided() {
359 let dir = TempDir::new().unwrap();
360 let config_path = dir.path().join("config.toml");
361 let section = "\n[supervisor]\nenabled = true\ntest_command = \"just check\"\n";
362 assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
363
364 let content = fs::read_to_string(&config_path).unwrap();
365 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
366 let supervisor = parsed.supervisor.unwrap();
367 assert!(supervisor.enabled);
368 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
369 }
370
371 #[test]
372 fn detect_speckit_section_returns_some_when_specify_present() {
373 let dir = setup_repo();
374 fs::create_dir_all(dir.path().join(".specify").join("specs")).unwrap();
375 let section = detect_speckit_section(dir.path()).expect("section");
376 assert!(section.contains("[specs]"));
377 assert!(section.contains("type = \"speckit\""));
378 assert!(section.contains("dir = \".specify/specs\""));
379 }
380
381 #[test]
382 fn detect_speckit_section_none_when_specify_missing() {
383 let dir = setup_repo();
384 assert!(detect_speckit_section(dir.path()).is_none());
385 }
386
387 #[test]
388 fn detect_speckit_section_none_when_specify_lacks_specs_subdir() {
389 let dir = setup_repo();
390 fs::create_dir_all(dir.path().join(".specify").join("memory")).unwrap();
391 assert!(detect_speckit_section(dir.path()).is_none());
392 }
393
394 #[test]
395 fn write_config_appends_specs_section_when_provided() {
396 let dir = TempDir::new().unwrap();
397 let config_path = dir.path().join("config.toml");
398 let specs_section = "\n[specs]\ntype = \"speckit\"\ndir = \".specify/specs\"\n";
399 assert!(write_config_if_missing(&config_path, None, Some(specs_section)).unwrap());
400
401 let content = fs::read_to_string(&config_path).unwrap();
402 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
403 let specs = parsed.specs.expect("specs section parsed");
404 assert_eq!(specs.spec_type.as_deref(), Some("speckit"));
405 assert_eq!(specs.dir.as_deref(), Some(".specify/specs"));
406 }
407
408 #[test]
409 fn appends_disabled_supervisor_section() {
410 let dir = TempDir::new().unwrap();
411 let config_path = dir.path().join("config.toml");
412 let section = "\n[supervisor]\nenabled = false\n";
413 assert!(write_config_if_missing(&config_path, Some(section), None).unwrap());
414
415 let content = fs::read_to_string(&config_path).unwrap();
416 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
417 let supervisor = parsed.supervisor.unwrap();
418 assert!(!supervisor.enabled);
419 }
420
421 #[test]
424 fn creates_gitignore_with_entry() {
425 let dir = setup_repo();
426 assert!(ensure_gitignore_entry(dir.path()).unwrap());
427 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
428 for entry in GITIGNORE_ENTRIES {
429 assert!(content.contains(entry), "missing {entry}");
430 }
431 }
432
433 #[test]
434 fn appends_to_existing_gitignore() {
435 let dir = setup_repo();
436 fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
437 assert!(ensure_gitignore_entry(dir.path()).unwrap());
438 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
439 assert!(content.contains("node_modules/"));
440 for entry in GITIGNORE_ENTRIES {
441 assert!(content.contains(entry), "missing {entry}");
442 }
443 }
444
445 #[test]
446 fn appends_newline_if_missing() {
447 let dir = setup_repo();
448 fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
449 assert!(ensure_gitignore_entry(dir.path()).unwrap());
450 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
451 assert!(content.contains("node_modules/\n"));
452 for entry in GITIGNORE_ENTRIES {
453 assert!(content.contains(entry), "missing {entry}");
454 }
455 }
456
457 #[test]
458 fn skips_when_all_entries_already_present() {
459 let dir = setup_repo();
460 let mut lines = String::from("node_modules/\n");
461 for entry in GITIGNORE_ENTRIES {
462 lines.push_str(entry);
463 lines.push('\n');
464 }
465 fs::write(dir.path().join(".gitignore"), lines).unwrap();
466 assert!(!ensure_gitignore_entry(dir.path()).unwrap());
467 }
468
469 #[test]
470 fn session_summary_added_alongside_logs() {
471 let dir = setup_repo();
472 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
473 assert!(ensure_gitignore_entry(dir.path()).unwrap());
474 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
475 assert!(content.contains(".git-paw/session-summary.md"));
476 assert_eq!(content.matches(".git-paw/logs/").count(), 1);
477 }
478
479 #[test]
482 fn has_section_detects_active_header() {
483 assert!(has_section("[supervisor]\nenabled = true\n", "supervisor"));
484 assert!(!has_section("# [supervisor]\n", "supervisor"));
485 assert!(!has_section("[broker]\n", "supervisor"));
486 }
487
488 #[test]
492 fn migrate_preserves_existing_supervisor_and_custom_broker_port() {
493 let dir = TempDir::new().unwrap();
494 let config_path = dir.path().join("config.toml");
495 let initial = r#"[broker]
496enabled = true
497port = 12345
498
499[supervisor]
500enabled = true
501cli = "echo"
502"#;
503 fs::write(&config_path, initial).unwrap();
504
505 let modified = migrate_existing_config(&config_path).unwrap();
506 assert!(
507 !modified,
508 "migrate must be a no-op when [supervisor] already exists"
509 );
510
511 let after = fs::read_to_string(&config_path).unwrap();
512 assert!(
513 after.contains("port = 12345"),
514 "custom broker port must be preserved verbatim; got:\n{after}"
515 );
516 assert!(
517 after.contains("[supervisor]"),
518 "supervisor header must be preserved; got:\n{after}"
519 );
520 assert!(
521 after.contains("cli = \"echo\""),
522 "supervisor cli must be preserved; got:\n{after}"
523 );
524
525 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
527 let supervisor = parsed.supervisor.expect("supervisor present");
528 assert!(supervisor.enabled);
529 assert_eq!(supervisor.cli.as_deref(), Some("echo"));
530 assert_eq!(parsed.broker.port, 12345);
531 }
532
533 #[test]
538 fn migrate_appends_supervisor_section_when_missing_and_keeps_broker_port() {
539 let dir = TempDir::new().unwrap();
540 let config_path = dir.path().join("config.toml");
541 let initial = "[broker]\nenabled = true\nport = 9119\n";
542 fs::write(&config_path, initial).unwrap();
543
544 let modified = migrate_existing_config(&config_path).unwrap();
545 assert!(
546 modified,
547 "migrate must report that the file was modified when appending"
548 );
549
550 let after = fs::read_to_string(&config_path).unwrap();
551 assert!(
553 after.contains("port = 9119"),
554 "broker port must survive migration; got:\n{after}"
555 );
556 assert!(
558 after.contains("[supervisor]"),
559 "supervisor section must be appended; got:\n{after}"
560 );
561
562 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
563 let supervisor = parsed.supervisor.expect("supervisor present");
564 assert!(
565 !supervisor.enabled,
566 "non-interactive migrate should opt out by default"
567 );
568 assert_eq!(parsed.broker.port, 9119);
569 }
570
571 #[test]
574 fn migrate_existing_config_is_idempotent() {
575 let dir = TempDir::new().unwrap();
576 let config_path = dir.path().join("config.toml");
577 fs::write(&config_path, "[broker]\nenabled = true\nport = 9119\n").unwrap();
578
579 migrate_existing_config(&config_path).unwrap();
580 let first = fs::read_to_string(&config_path).unwrap();
581 let modified = migrate_existing_config(&config_path).unwrap();
582 let second = fs::read_to_string(&config_path).unwrap();
583
584 assert!(!modified, "second migrate must be a no-op");
585 assert_eq!(first, second);
586 }
587
588 #[test]
595 fn migrate_against_uncommented_supervisor_does_not_create_duplicate() {
596 let dir = TempDir::new().unwrap();
597 let config_path = dir.path().join("config.toml");
598 let initial = r#"# user-authored config
599branch_prefix = "feat/"
600
601[supervisor]
602enabled = true
603cli = "claude-oss"
604test_command = "just check"
605"#;
606 fs::write(&config_path, initial).unwrap();
607
608 let modified = migrate_existing_config(&config_path).unwrap();
609 assert!(
610 !modified,
611 "migrate must be a no-op when an uncommented [supervisor] block already exists"
612 );
613
614 let after = fs::read_to_string(&config_path).unwrap();
615 let header_count = after.lines().filter(|l| l.trim() == "[supervisor]").count();
616 assert_eq!(
617 header_count, 1,
618 "exactly one [supervisor] header must exist; found {header_count} in:\n{after}"
619 );
620
621 let parsed: crate::config::PawConfig = toml::from_str(&after).expect(
623 "config with uncommented [supervisor] must parse cleanly after migrate (no duplicate key)",
624 );
625 let supervisor = parsed.supervisor.expect("supervisor present");
626 assert!(supervisor.enabled);
627 assert_eq!(supervisor.cli.as_deref(), Some("claude-oss"));
628 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
629 }
630
631 #[test]
643 fn migrate_against_branch_prefix_only_preserves_user_field() {
644 let dir = TempDir::new().unwrap();
645 let config_path = dir.path().join("config.toml");
646 fs::write(&config_path, "branch_prefix = \"feat/\"\n").unwrap();
647
648 let modified = migrate_existing_config(&config_path).unwrap();
649 assert!(
650 modified,
651 "migrate must append the missing [supervisor] section"
652 );
653
654 let after = fs::read_to_string(&config_path).unwrap();
655 assert!(
656 after.contains("branch_prefix = \"feat/\""),
657 "user branch_prefix must be preserved verbatim; got:\n{after}"
658 );
659 assert!(
660 after.contains("[supervisor]"),
661 "supervisor section must be appended; got:\n{after}"
662 );
663
664 let parsed: crate::config::PawConfig = toml::from_str(&after)
666 .expect("config with branch_prefix + appended supervisor must parse cleanly");
667 assert_eq!(parsed.branch_prefix.as_deref(), Some("feat/"));
668 }
669
670 #[test]
673 fn idempotent_gitignore() {
674 let dir = setup_repo();
675 ensure_gitignore_entry(dir.path()).unwrap();
676 let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
677 ensure_gitignore_entry(dir.path()).unwrap();
678 let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
679 assert_eq!(first, second);
680 }
681}