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.
18const GITIGNORE_ENTRIES: &[&str] = &[".git-paw/logs/", ".git-paw/session-summary.md"];
19
20/// Runs the `git paw init` command.
21///
22/// Creates `.git-paw/` directory structure, generates a default config,
23/// and manages `.gitignore`. Idempotent — running twice produces identical
24/// results.
25pub 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    // 1. Create .git-paw/ directory
35    let created_dir = create_dir_if_missing(&paw_dir)?;
36    if created_dir {
37        println!("  Created .git-paw/");
38    }
39
40    // 2. Create .git-paw/logs/ directory
41    let created_logs = create_dir_if_missing(&logs_dir)?;
42    if created_logs {
43        println!("  Created .git-paw/logs/");
44    }
45
46    // 3. Generate or migrate config. For a fresh config, prompt for supervisor
47    //    preferences. For an existing config without a [supervisor] section,
48    //    append one (prompting if stdin is interactive). Init never mutates
49    //    existing sections — only appends missing ones.
50    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    // 4. Manage .gitignore
65    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
79/// Creates a directory if it doesn't exist. Returns `true` if created.
80fn 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
89/// Appends any missing sections to an existing `config.toml`. Returns `true`
90/// if the file was modified. Does not touch any existing field — this is the
91/// safe upgrade path for new config sections added across versions.
92fn 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    // [supervisor] — the only section currently managed by migration. We
99    // detect presence with a simple line-based scan rather than parsing TOML
100    // so we don't lose comments or reorder fields on round-trip.
101    if !has_section(&existing, "supervisor") {
102        let section = prompt_supervisor_section()?;
103        appended.push_str(&section);
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
121/// Returns `true` if a non-commented `[section]` header exists in `content`.
122fn 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
130/// Writes the default config if the file doesn't already exist. Returns `true` if written.
131///
132/// If `supervisor_section` is `Some`, it is appended to the generated config so
133/// the user's init-time choice is persisted.
134fn 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
150/// Prompts the user for their supervisor preferences and returns a TOML
151/// `[supervisor]` section to append to the generated config.
152///
153/// If the user declines, an explicit `enabled = false` section is returned so
154/// that future `git paw start` calls do not re-prompt.
155fn prompt_supervisor_section() -> Result<String, PawError> {
156    // In non-interactive contexts (CI, tests, piped stdin) fall back to an
157    // explicit opt-out so init remains scriptable.
158    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
188/// Ensures `.gitignore` contains all managed entries. Returns `true` if modified.
189fn 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        // Create a minimal .git dir so validate_repo-like checks work
235        fs::create_dir(dir.path().join(".git")).unwrap();
236        dir
237    }
238
239    // --- create_dir_if_missing ---
240
241    #[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    // --- write_config_if_missing ---
258
259    #[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    // --- ensure_gitignore_entry ---
305
306    #[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    // --- migrate_existing_config ---
363
364    #[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    /// Migration does not touch existing sections. A config already containing
372    /// `[supervisor]` plus a custom `[broker]` port must round-trip with both
373    /// sections and the custom port intact.
374    #[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        // The TOML must still parse to a config with the expected fields.
409        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    /// When `[supervisor]` is missing, migrate appends a section. Stdin in
417    /// tests is non-interactive, so the appended section is the explicit
418    /// opt-out (`enabled = false`). The pre-existing `[broker]` section and
419    /// its custom port must remain untouched.
420    #[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        // Original section preserved.
435        assert!(
436            after.contains("port = 9119"),
437            "broker port must survive migration; got:\n{after}"
438        );
439        // Section appended.
440        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    /// Running migrate twice must produce identical content — the second run
455    /// has nothing to do.
456    #[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    // --- Idempotency ---
472
473    #[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}