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
20pub fn run_init() -> Result<(), PawError> {
26 let cwd = std::env::current_dir()
27 .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
28 let repo_root = git::validate_repo(&cwd)?;
29
30 let paw_dir = repo_root.join(".git-paw");
31 let logs_dir = paw_dir.join("logs");
32 let config_path = paw_dir.join("config.toml");
33
34 let created_dir = create_dir_if_missing(&paw_dir)?;
36 if created_dir {
37 println!(" Created .git-paw/");
38 }
39
40 let created_logs = create_dir_if_missing(&logs_dir)?;
42 if created_logs {
43 println!(" Created .git-paw/logs/");
44 }
45
46 let (created_config, migrated_config) = if config_path.exists() {
51 let migrated = migrate_existing_config(&config_path)?;
52 (false, migrated)
53 } else {
54 let supervisor_section = prompt_supervisor_section()?;
55 write_config_if_missing(&config_path, Some(&supervisor_section))?;
56 (true, false)
57 };
58 if created_config {
59 println!(" Created .git-paw/config.toml");
60 } else if migrated_config {
61 println!(" Updated .git-paw/config.toml (added missing sections)");
62 }
63
64 let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
66 if updated_gitignore {
67 println!(" Updated .gitignore");
68 }
69
70 if !created_dir && !created_logs && !created_config && !migrated_config && !updated_gitignore {
71 println!("Already initialized. Nothing to do.");
72 } else {
73 println!("Initialized git-paw.");
74 }
75
76 Ok(())
77}
78
79fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
81 if path.is_dir() {
82 return Ok(false);
83 }
84 fs::create_dir_all(path)
85 .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
86 Ok(true)
87}
88
89fn migrate_existing_config(path: &Path) -> Result<bool, PawError> {
93 let existing = fs::read_to_string(path)
94 .map_err(|e| PawError::InitError(format!("failed to read config: {e}")))?;
95
96 let mut appended = String::new();
97
98 if !has_section(&existing, "supervisor") {
102 let section = prompt_supervisor_section()?;
103 appended.push_str(§ion);
104 }
105
106 if appended.is_empty() {
107 return Ok(false);
108 }
109
110 let mut new_content = existing;
111 if !new_content.ends_with('\n') {
112 new_content.push('\n');
113 }
114 new_content.push_str(&appended);
115
116 fs::write(path, new_content)
117 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
118 Ok(true)
119}
120
121fn has_section(content: &str, section: &str) -> bool {
123 let header = format!("[{section}]");
124 content.lines().any(|line| {
125 let trimmed = line.trim_start();
126 !trimmed.starts_with('#') && trimmed.trim_end() == header
127 })
128}
129
130fn write_config_if_missing(
135 path: &Path,
136 supervisor_section: Option<&str>,
137) -> Result<bool, PawError> {
138 if path.exists() {
139 return Ok(false);
140 }
141 let mut content = config::generate_default_config();
142 if let Some(section) = supervisor_section {
143 content.push_str(section);
144 }
145 fs::write(path, content)
146 .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
147 Ok(true)
148}
149
150fn prompt_supervisor_section() -> Result<String, PawError> {
156 if !std::io::stdin().is_terminal() {
159 return Ok("\n[supervisor]\nenabled = false\n".to_string());
160 }
161
162 let enabled = Confirm::new()
163 .with_prompt("Enable supervisor mode by default?")
164 .default(false)
165 .interact()
166 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
167
168 if !enabled {
169 return Ok("\n[supervisor]\nenabled = false\n".to_string());
170 }
171
172 let test_command: String = Input::new()
173 .with_prompt("Test command to run after each agent completes (e.g. 'just check', leave empty to skip)")
174 .allow_empty(true)
175 .interact_text()
176 .map_err(|e| PawError::InitError(format!("prompt failed: {e}")))?;
177
178 let mut section = String::from("\n[supervisor]\nenabled = true\n");
179 let trimmed = test_command.trim();
180 if !trimmed.is_empty() {
181 let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
182 writeln!(section, "test_command = \"{escaped}\"")
183 .map_err(|e| PawError::InitError(format!("format supervisor section: {e}")))?;
184 }
185 Ok(section)
186}
187
188fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
190 let gitignore_path = repo_root.join(".gitignore");
191
192 let existing = match fs::read_to_string(&gitignore_path) {
193 Ok(content) => content,
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
195 Err(e) => {
196 return Err(PawError::InitError(format!(
197 "failed to read .gitignore: {e}"
198 )));
199 }
200 };
201
202 let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
203 let missing: Vec<&&str> = GITIGNORE_ENTRIES
204 .iter()
205 .filter(|e| !existing_lines.contains(**e))
206 .collect();
207
208 if missing.is_empty() {
209 return Ok(false);
210 }
211
212 let mut content = existing;
213 if !content.is_empty() && !content.ends_with('\n') {
214 content.push('\n');
215 }
216 for entry in missing {
217 content.push_str(entry);
218 content.push('\n');
219 }
220
221 fs::write(&gitignore_path, content)
222 .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
223
224 Ok(true)
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use tempfile::TempDir;
231
232 fn setup_repo() -> TempDir {
233 let dir = TempDir::new().unwrap();
234 fs::create_dir(dir.path().join(".git")).unwrap();
236 dir
237 }
238
239 #[test]
242 fn creates_directory_when_missing() {
243 let dir = TempDir::new().unwrap();
244 let target = dir.path().join("new-dir");
245 assert!(create_dir_if_missing(&target).unwrap());
246 assert!(target.is_dir());
247 }
248
249 #[test]
250 fn skips_existing_directory() {
251 let dir = TempDir::new().unwrap();
252 let target = dir.path().join("existing");
253 fs::create_dir(&target).unwrap();
254 assert!(!create_dir_if_missing(&target).unwrap());
255 }
256
257 #[test]
260 fn writes_config_when_missing() {
261 let dir = TempDir::new().unwrap();
262 let config_path = dir.path().join("config.toml");
263 assert!(write_config_if_missing(&config_path, None).unwrap());
264 let content = fs::read_to_string(&config_path).unwrap();
265 assert!(content.contains("default_cli"));
266 }
267
268 #[test]
269 fn skips_existing_config() {
270 let dir = TempDir::new().unwrap();
271 let config_path = dir.path().join("config.toml");
272 fs::write(&config_path, "existing").unwrap();
273 assert!(!write_config_if_missing(&config_path, None).unwrap());
274 assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
275 }
276
277 #[test]
278 fn appends_supervisor_section_when_provided() {
279 let dir = TempDir::new().unwrap();
280 let config_path = dir.path().join("config.toml");
281 let section = "\n[supervisor]\nenabled = true\ntest_command = \"just check\"\n";
282 assert!(write_config_if_missing(&config_path, Some(section)).unwrap());
283
284 let content = fs::read_to_string(&config_path).unwrap();
285 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
286 let supervisor = parsed.supervisor.unwrap();
287 assert!(supervisor.enabled);
288 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
289 }
290
291 #[test]
292 fn appends_disabled_supervisor_section() {
293 let dir = TempDir::new().unwrap();
294 let config_path = dir.path().join("config.toml");
295 let section = "\n[supervisor]\nenabled = false\n";
296 assert!(write_config_if_missing(&config_path, Some(section)).unwrap());
297
298 let content = fs::read_to_string(&config_path).unwrap();
299 let parsed: crate::config::PawConfig = toml::from_str(&content).unwrap();
300 let supervisor = parsed.supervisor.unwrap();
301 assert!(!supervisor.enabled);
302 }
303
304 #[test]
307 fn creates_gitignore_with_entry() {
308 let dir = setup_repo();
309 assert!(ensure_gitignore_entry(dir.path()).unwrap());
310 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
311 for entry in GITIGNORE_ENTRIES {
312 assert!(content.contains(entry), "missing {entry}");
313 }
314 }
315
316 #[test]
317 fn appends_to_existing_gitignore() {
318 let dir = setup_repo();
319 fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
320 assert!(ensure_gitignore_entry(dir.path()).unwrap());
321 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
322 assert!(content.contains("node_modules/"));
323 for entry in GITIGNORE_ENTRIES {
324 assert!(content.contains(entry), "missing {entry}");
325 }
326 }
327
328 #[test]
329 fn appends_newline_if_missing() {
330 let dir = setup_repo();
331 fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
332 assert!(ensure_gitignore_entry(dir.path()).unwrap());
333 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
334 assert!(content.contains("node_modules/\n"));
335 for entry in GITIGNORE_ENTRIES {
336 assert!(content.contains(entry), "missing {entry}");
337 }
338 }
339
340 #[test]
341 fn skips_when_all_entries_already_present() {
342 let dir = setup_repo();
343 let mut lines = String::from("node_modules/\n");
344 for entry in GITIGNORE_ENTRIES {
345 lines.push_str(entry);
346 lines.push('\n');
347 }
348 fs::write(dir.path().join(".gitignore"), lines).unwrap();
349 assert!(!ensure_gitignore_entry(dir.path()).unwrap());
350 }
351
352 #[test]
353 fn session_summary_added_alongside_logs() {
354 let dir = setup_repo();
355 fs::write(dir.path().join(".gitignore"), ".git-paw/logs/\n").unwrap();
356 assert!(ensure_gitignore_entry(dir.path()).unwrap());
357 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
358 assert!(content.contains(".git-paw/session-summary.md"));
359 assert_eq!(content.matches(".git-paw/logs/").count(), 1);
360 }
361
362 #[test]
365 fn has_section_detects_active_header() {
366 assert!(has_section("[supervisor]\nenabled = true\n", "supervisor"));
367 assert!(!has_section("# [supervisor]\n", "supervisor"));
368 assert!(!has_section("[broker]\n", "supervisor"));
369 }
370
371 #[test]
375 fn migrate_preserves_existing_supervisor_and_custom_broker_port() {
376 let dir = TempDir::new().unwrap();
377 let config_path = dir.path().join("config.toml");
378 let initial = r#"[broker]
379enabled = true
380port = 12345
381
382[supervisor]
383enabled = true
384cli = "echo"
385"#;
386 fs::write(&config_path, initial).unwrap();
387
388 let modified = migrate_existing_config(&config_path).unwrap();
389 assert!(
390 !modified,
391 "migrate must be a no-op when [supervisor] already exists"
392 );
393
394 let after = fs::read_to_string(&config_path).unwrap();
395 assert!(
396 after.contains("port = 12345"),
397 "custom broker port must be preserved verbatim; got:\n{after}"
398 );
399 assert!(
400 after.contains("[supervisor]"),
401 "supervisor header must be preserved; got:\n{after}"
402 );
403 assert!(
404 after.contains("cli = \"echo\""),
405 "supervisor cli must be preserved; got:\n{after}"
406 );
407
408 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
410 let supervisor = parsed.supervisor.expect("supervisor present");
411 assert!(supervisor.enabled);
412 assert_eq!(supervisor.cli.as_deref(), Some("echo"));
413 assert_eq!(parsed.broker.port, 12345);
414 }
415
416 #[test]
421 fn migrate_appends_supervisor_section_when_missing_and_keeps_broker_port() {
422 let dir = TempDir::new().unwrap();
423 let config_path = dir.path().join("config.toml");
424 let initial = "[broker]\nenabled = true\nport = 9119\n";
425 fs::write(&config_path, initial).unwrap();
426
427 let modified = migrate_existing_config(&config_path).unwrap();
428 assert!(
429 modified,
430 "migrate must report that the file was modified when appending"
431 );
432
433 let after = fs::read_to_string(&config_path).unwrap();
434 assert!(
436 after.contains("port = 9119"),
437 "broker port must survive migration; got:\n{after}"
438 );
439 assert!(
441 after.contains("[supervisor]"),
442 "supervisor section must be appended; got:\n{after}"
443 );
444
445 let parsed: crate::config::PawConfig = toml::from_str(&after).unwrap();
446 let supervisor = parsed.supervisor.expect("supervisor present");
447 assert!(
448 !supervisor.enabled,
449 "non-interactive migrate should opt out by default"
450 );
451 assert_eq!(parsed.broker.port, 9119);
452 }
453
454 #[test]
457 fn migrate_existing_config_is_idempotent() {
458 let dir = TempDir::new().unwrap();
459 let config_path = dir.path().join("config.toml");
460 fs::write(&config_path, "[broker]\nenabled = true\nport = 9119\n").unwrap();
461
462 migrate_existing_config(&config_path).unwrap();
463 let first = fs::read_to_string(&config_path).unwrap();
464 let modified = migrate_existing_config(&config_path).unwrap();
465 let second = fs::read_to_string(&config_path).unwrap();
466
467 assert!(!modified, "second migrate must be a no-op");
468 assert_eq!(first, second);
469 }
470
471 #[test]
474 fn idempotent_gitignore() {
475 let dir = setup_repo();
476 ensure_gitignore_entry(dir.path()).unwrap();
477 let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
478 ensure_gitignore_entry(dir.path()).unwrap();
479 let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
480 assert_eq!(first, second);
481 }
482}