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::fs;
7use std::path::Path;
8
9use crate::config;
10use crate::error::PawError;
11use crate::git;
12
13/// Gitignore entry for session logs.
14const GITIGNORE_ENTRY: &str = ".git-paw/logs/";
15
16/// Runs the `git paw init` command.
17///
18/// Creates `.git-paw/` directory structure, generates a default config,
19/// and manages `.gitignore`. Idempotent — running twice produces identical
20/// results.
21pub fn run_init() -> Result<(), PawError> {
22    let cwd = std::env::current_dir()
23        .map_err(|e| PawError::InitError(format!("cannot read current directory: {e}")))?;
24    let repo_root = git::validate_repo(&cwd)?;
25
26    let paw_dir = repo_root.join(".git-paw");
27    let logs_dir = paw_dir.join("logs");
28    let config_path = paw_dir.join("config.toml");
29
30    // 1. Create .git-paw/ directory
31    let created_dir = create_dir_if_missing(&paw_dir)?;
32    if created_dir {
33        println!("  Created .git-paw/");
34    }
35
36    // 2. Create .git-paw/logs/ directory
37    let created_logs = create_dir_if_missing(&logs_dir)?;
38    if created_logs {
39        println!("  Created .git-paw/logs/");
40    }
41
42    // 3. Generate default config (only if not present)
43    let created_config = write_config_if_missing(&config_path)?;
44    if created_config {
45        println!("  Created .git-paw/config.toml");
46    }
47
48    // 4. Manage .gitignore
49    let updated_gitignore = ensure_gitignore_entry(&repo_root)?;
50    if updated_gitignore {
51        println!("  Updated .gitignore");
52    }
53
54    if !created_dir && !created_logs && !created_config && !updated_gitignore {
55        println!("Already initialized. Nothing to do.");
56    } else {
57        println!("Initialized git-paw.");
58    }
59
60    Ok(())
61}
62
63/// Creates a directory if it doesn't exist. Returns `true` if created.
64fn create_dir_if_missing(path: &Path) -> Result<bool, PawError> {
65    if path.is_dir() {
66        return Ok(false);
67    }
68    fs::create_dir_all(path)
69        .map_err(|e| PawError::InitError(format!("failed to create '{}': {e}", path.display())))?;
70    Ok(true)
71}
72
73/// Writes the default config if the file doesn't already exist. Returns `true` if written.
74fn write_config_if_missing(path: &Path) -> Result<bool, PawError> {
75    if path.exists() {
76        return Ok(false);
77    }
78    let content = config::generate_default_config();
79    fs::write(path, content)
80        .map_err(|e| PawError::InitError(format!("failed to write config: {e}")))?;
81    Ok(true)
82}
83
84/// Ensures `.gitignore` contains the logs entry. Returns `true` if modified.
85fn ensure_gitignore_entry(repo_root: &Path) -> Result<bool, PawError> {
86    let gitignore_path = repo_root.join(".gitignore");
87
88    let existing = match fs::read_to_string(&gitignore_path) {
89        Ok(content) => content,
90        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
91        Err(e) => {
92            return Err(PawError::InitError(format!(
93                "failed to read .gitignore: {e}"
94            )));
95        }
96    };
97
98    // Check if already present
99    if existing.lines().any(|line| line.trim() == GITIGNORE_ENTRY) {
100        return Ok(false);
101    }
102
103    // Append entry, ensuring proper newline handling
104    let mut content = existing;
105    if !content.is_empty() && !content.ends_with('\n') {
106        content.push('\n');
107    }
108    content.push_str(GITIGNORE_ENTRY);
109    content.push('\n');
110
111    fs::write(&gitignore_path, content)
112        .map_err(|e| PawError::InitError(format!("failed to write .gitignore: {e}")))?;
113
114    Ok(true)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use tempfile::TempDir;
121
122    fn setup_repo() -> TempDir {
123        let dir = TempDir::new().unwrap();
124        // Create a minimal .git dir so validate_repo-like checks work
125        fs::create_dir(dir.path().join(".git")).unwrap();
126        dir
127    }
128
129    // --- create_dir_if_missing ---
130
131    #[test]
132    fn creates_directory_when_missing() {
133        let dir = TempDir::new().unwrap();
134        let target = dir.path().join("new-dir");
135        assert!(create_dir_if_missing(&target).unwrap());
136        assert!(target.is_dir());
137    }
138
139    #[test]
140    fn skips_existing_directory() {
141        let dir = TempDir::new().unwrap();
142        let target = dir.path().join("existing");
143        fs::create_dir(&target).unwrap();
144        assert!(!create_dir_if_missing(&target).unwrap());
145    }
146
147    // --- write_config_if_missing ---
148
149    #[test]
150    fn writes_config_when_missing() {
151        let dir = TempDir::new().unwrap();
152        let config_path = dir.path().join("config.toml");
153        assert!(write_config_if_missing(&config_path).unwrap());
154        let content = fs::read_to_string(&config_path).unwrap();
155        assert!(content.contains("default_cli"));
156    }
157
158    #[test]
159    fn skips_existing_config() {
160        let dir = TempDir::new().unwrap();
161        let config_path = dir.path().join("config.toml");
162        fs::write(&config_path, "existing").unwrap();
163        assert!(!write_config_if_missing(&config_path).unwrap());
164        assert_eq!(fs::read_to_string(&config_path).unwrap(), "existing");
165    }
166
167    // --- ensure_gitignore_entry ---
168
169    #[test]
170    fn creates_gitignore_with_entry() {
171        let dir = setup_repo();
172        assert!(ensure_gitignore_entry(dir.path()).unwrap());
173        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
174        assert!(content.contains(GITIGNORE_ENTRY));
175    }
176
177    #[test]
178    fn appends_to_existing_gitignore() {
179        let dir = setup_repo();
180        fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
181        assert!(ensure_gitignore_entry(dir.path()).unwrap());
182        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
183        assert!(content.contains("node_modules/"));
184        assert!(content.contains(GITIGNORE_ENTRY));
185    }
186
187    #[test]
188    fn appends_newline_if_missing() {
189        let dir = setup_repo();
190        fs::write(dir.path().join(".gitignore"), "node_modules/").unwrap();
191        assert!(ensure_gitignore_entry(dir.path()).unwrap());
192        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
193        assert!(content.contains("node_modules/\n"));
194        assert!(content.contains(GITIGNORE_ENTRY));
195    }
196
197    #[test]
198    fn skips_when_entry_already_present() {
199        let dir = setup_repo();
200        fs::write(
201            dir.path().join(".gitignore"),
202            format!("node_modules/\n{GITIGNORE_ENTRY}\n"),
203        )
204        .unwrap();
205        assert!(!ensure_gitignore_entry(dir.path()).unwrap());
206    }
207
208    // --- Idempotency ---
209
210    #[test]
211    fn idempotent_gitignore() {
212        let dir = setup_repo();
213        ensure_gitignore_entry(dir.path()).unwrap();
214        let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
215        ensure_gitignore_entry(dir.path()).unwrap();
216        let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
217        assert_eq!(first, second);
218    }
219}