Skip to main content

joy_core/
init.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::path::{Path, PathBuf};
5
6use crate::embedded::{self, EmbeddedFile};
7use crate::error::JoyError;
8use crate::model::project::{derive_acronym, Project};
9use crate::store;
10use crate::vcs::{default_vcs, Vcs};
11
12pub const HOOK_FILES: &[EmbeddedFile] = &[EmbeddedFile {
13    content: include_str!("../data/hooks/commit-msg"),
14    target: "hooks/commit-msg",
15    executable: true,
16}];
17
18pub const CONFIG_FILES: &[EmbeddedFile] = &[EmbeddedFile {
19    content: include_str!("../data/config.defaults.yaml"),
20    target: "config.defaults.yaml",
21    executable: false,
22}];
23
24pub const PROJECT_FILES: &[EmbeddedFile] = &[EmbeddedFile {
25    content: include_str!("../data/project.defaults.yaml"),
26    target: "project.defaults.yaml",
27    executable: false,
28}];
29
30pub struct InitOptions {
31    pub root: PathBuf,
32    pub name: Option<String>,
33    pub acronym: Option<String>,
34    /// Override the creator member email. Falls back to git config user.email.
35    pub user: Option<String>,
36    /// Project language code (ISO 639-1, e.g. "en", "de"). Defaults to "en".
37    pub language: Option<String>,
38}
39
40#[derive(Debug)]
41pub struct InitResult {
42    pub project_dir: PathBuf,
43    pub git_initialized: bool,
44    pub git_existed: bool,
45}
46
47pub struct OnboardResult {
48    pub hooks_installed: bool,
49    pub hooks_already_set: bool,
50}
51
52pub fn init(options: InitOptions) -> Result<InitResult, JoyError> {
53    let root = &options.root;
54    let joy_dir = store::joy_dir(root);
55
56    if store::is_initialized(root) {
57        return Err(JoyError::AlreadyInitialized(joy_dir));
58    }
59
60    // Detect or initialize git
61    let vcs = default_vcs();
62    let git_existed = vcs.is_repo(root);
63    let mut git_initialized = false;
64    if !git_existed {
65        vcs.init_repo(root)?;
66        git_initialized = true;
67    }
68
69    // Create directory structure
70    let dirs = [
71        store::ITEMS_DIR,
72        store::MILESTONES_DIR,
73        store::RELEASES_DIR,
74        store::AI_AGENTS_DIR,
75        store::AI_JOBS_DIR,
76        store::LOG_DIR,
77    ];
78    for dir in &dirs {
79        let path = joy_dir.join(dir);
80        std::fs::create_dir_all(&path).map_err(|e| JoyError::CreateDir {
81            path: path.clone(),
82            source: e,
83        })?;
84    }
85
86    // Derive project name and acronym
87    let name = options.name.unwrap_or_else(|| {
88        root.file_name()
89            .and_then(|n| n.to_str())
90            .unwrap_or("project")
91            .to_string()
92    });
93    let acronym = options.acronym.unwrap_or_else(|| derive_acronym(&name));
94
95    // Write config and project defaults (embedded files)
96    embedded::sync_files(root, CONFIG_FILES)?;
97    embedded::sync_files(root, PROJECT_FILES)?;
98
99    let mut project = Project::new(name, Some(acronym));
100    if let Some(lang) = options.language.filter(|s| !s.is_empty()) {
101        project.language = lang;
102    }
103
104    // Register the project creator as a member with all capabilities.
105    // Prefer an explicit override, fall back to git config user.email.
106    let creator_email = options
107        .user
108        .filter(|s| !s.is_empty())
109        .or_else(|| vcs.user_email().ok().filter(|s| !s.is_empty()));
110    if let Some(email) = creator_email {
111        project.members.insert(
112            email,
113            crate::model::project::Member::new(crate::model::project::MemberCapabilities::All),
114        );
115    }
116
117    store::write_yaml(&joy_dir.join(store::PROJECT_FILE), &project)?;
118    let project_rel = format!("{}/{}", store::JOY_DIR, store::PROJECT_FILE);
119    let defaults_rel = format!("{}/{}", store::JOY_DIR, store::CONFIG_DEFAULTS_FILE);
120    crate::git_ops::auto_git_add(root, &[&project_rel, &defaults_rel]);
121
122    // Ensure .joy/credentials.yaml is in .gitignore
123    ensure_gitignore(root)?;
124
125    // Install hooks
126    install_hooks(root)?;
127
128    Ok(InitResult {
129        project_dir: joy_dir,
130        git_initialized,
131        git_existed,
132    })
133}
134
135/// Onboard an existing project: set up local environment (hooks, etc.).
136pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
137    embedded::sync_files(root, CONFIG_FILES)?;
138    embedded::sync_files(root, PROJECT_FILES)?;
139    install_hooks(root)
140}
141
142/// Sync hook files and set core.hooksPath.
143fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
144    let actions = embedded::sync_files(root, HOOK_FILES)?;
145    let hooks_installed = actions.iter().any(|a| a.action != "up to date");
146
147    // Set core.hooksPath if not already pointing to .joy/hooks
148    let vcs = default_vcs();
149    let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
150    let already_set = current == ".joy/hooks";
151
152    if !already_set {
153        vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
154    }
155
156    Ok(OnboardResult {
157        hooks_installed,
158        hooks_already_set: already_set,
159    })
160}
161
162pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
163pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
164
165pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
166    (".joy/config.yaml", "personal config"),
167    (".joy/credentials.yaml", "secrets"),
168    (".joy/hooks/", "git hooks"),
169    (".joy/project.defaults.yaml", "embedded project defaults"),
170];
171
172/// Update the joy-managed block in .gitignore with the given entries.
173/// Each entry is (path, comment). Replaces the block if it exists, appends otherwise.
174pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
175    let gitignore_path = root.join(".gitignore");
176
177    let mut lines = String::new();
178    for (path, _comment) in entries {
179        lines.push_str(path);
180        lines.push('\n');
181    }
182    let block = format!(
183        "{}\n{}{}",
184        GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
185    );
186
187    let content = if gitignore_path.is_file() {
188        let existing =
189            std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
190                path: gitignore_path.clone(),
191                source: e,
192            })?;
193        if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
194            let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
195            let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
196            let mut updated = String::new();
197            updated.push_str(&existing[..start]);
198            updated.push_str(&block);
199            updated.push_str(&existing[end..]);
200            updated
201        } else {
202            let trimmed = existing.trim_end();
203            if trimmed.is_empty() {
204                format!("{}\n", block)
205            } else {
206                format!("{}\n\n{}\n", trimmed, block)
207            }
208        }
209    } else {
210        format!("{}\n", block)
211    };
212
213    std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
214        path: gitignore_path,
215        source: e,
216    })?;
217    crate::git_ops::auto_git_add(root, &[".gitignore"]);
218    Ok(())
219}
220
221fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
222    update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use tempfile::tempdir;
229
230    #[test]
231    fn init_creates_directory_structure() {
232        let dir = tempdir().unwrap();
233        let result = init(InitOptions {
234            root: dir.path().to_path_buf(),
235            name: Some("Test Project".into()),
236            acronym: Some("TP".into()),
237            user: None,
238            language: None,
239        })
240        .unwrap();
241
242        assert!(result.project_dir.join("items").is_dir());
243        assert!(result.project_dir.join("milestones").is_dir());
244        assert!(result.project_dir.join("ai/agents").is_dir());
245        assert!(result.project_dir.join("ai/jobs").is_dir());
246        assert!(result.project_dir.join("logs").is_dir());
247        assert!(result.project_dir.join("config.defaults.yaml").is_file());
248        assert!(result.project_dir.join("project.yaml").is_file());
249    }
250
251    #[test]
252    fn init_writes_project_metadata() {
253        let dir = tempdir().unwrap();
254        init(InitOptions {
255            root: dir.path().to_path_buf(),
256            name: Some("My App".into()),
257            acronym: Some("MA".into()),
258            user: None,
259            language: None,
260        })
261        .unwrap();
262
263        let project: Project =
264            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
265        assert_eq!(project.name, "My App");
266        assert_eq!(project.acronym.as_deref(), Some("MA"));
267    }
268
269    #[test]
270    fn init_derives_name_from_directory() {
271        let dir = tempdir().unwrap();
272        init(InitOptions {
273            root: dir.path().to_path_buf(),
274            name: None,
275            acronym: None,
276            user: None,
277            language: None,
278        })
279        .unwrap();
280
281        let project: Project =
282            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
283        // tempdir names vary, just check it's not empty
284        assert!(!project.name.is_empty());
285        assert!(project.acronym.is_some());
286    }
287
288    #[test]
289    fn init_fails_if_already_initialized() {
290        let dir = tempdir().unwrap();
291        init(InitOptions {
292            root: dir.path().to_path_buf(),
293            name: Some("Test".into()),
294            acronym: None,
295            user: None,
296            language: None,
297        })
298        .unwrap();
299
300        let err = init(InitOptions {
301            root: dir.path().to_path_buf(),
302            name: Some("Test".into()),
303            acronym: None,
304            user: None,
305            language: None,
306        })
307        .unwrap_err();
308
309        assert!(matches!(err, JoyError::AlreadyInitialized(_)));
310    }
311
312    #[test]
313    fn init_creates_gitignore_with_credentials_entry() {
314        let dir = tempdir().unwrap();
315        init(InitOptions {
316            root: dir.path().to_path_buf(),
317            name: Some("Test".into()),
318            acronym: None,
319            user: None,
320            language: None,
321        })
322        .unwrap();
323
324        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
325        assert!(content.contains(".joy/credentials.yaml"));
326        assert!(content.contains(".joy/config.yaml"));
327    }
328
329    #[test]
330    fn init_does_not_duplicate_gitignore_block() {
331        let dir = tempdir().unwrap();
332        // First init creates the block
333        init(InitOptions {
334            root: dir.path().to_path_buf(),
335            name: Some("Test".into()),
336            acronym: None,
337            user: None,
338            language: None,
339        })
340        .unwrap();
341        let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
342
343        // Re-running ensure_gitignore should not duplicate
344        super::ensure_gitignore(dir.path()).unwrap();
345        let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
346
347        assert_eq!(first, second);
348        assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
349    }
350
351    #[test]
352    fn init_initializes_git_if_needed() {
353        let dir = tempdir().unwrap();
354        let result = init(InitOptions {
355            root: dir.path().to_path_buf(),
356            name: Some("Test".into()),
357            acronym: None,
358            user: None,
359            language: None,
360        })
361        .unwrap();
362
363        assert!(result.git_initialized);
364        assert!(!result.git_existed);
365        assert!(dir.path().join(".git").is_dir());
366    }
367}